Your agent's approval arrives in Slack as a screenshot with two buttons — and the click is the answer. No public endpoint.
npm install handraise handraise-slackNot published yet. This needs
channels, which landed in handraise 0.5.1. Until that is on npm, check both repos out side by side — see Version note.
import { raiseHand } from "handraise"
import { slack } from "handraise-slack"
const { SLACK_BOT_TOKEN = "", SLACK_APP_TOKEN = "", SLACK_CHANNEL = "" } = process.env
// Build it once and reuse it: one channel owns one connection. See below.
const channel = slack({
botToken: SLACK_BOT_TOKEN,
appToken: SLACK_APP_TOKEN,
channel: SLACK_CHANNEL,
})
await raiseHand(page, {
mode: "approval",
reason: "The agent may not move money without a human",
action: "Submit $12,430 vendor payment to Acme GmbH",
channels: [channel],
})
// outcome "approved" → somebody pressed Approve, in Slack or on the phoneThat is the whole integration. slack() returns a
HandoffChannel; handraise calls
it when the handoff starts and hands it the screenshot the phone would show,
plus a way to answer.
One dependency, ws. Node 20 has no global WebSocket and this
package needs one. Everything else is fetch and node:crypto.
Slack's usual way to receive a button click is an interactivity request URL: a public HTTPS endpoint Slack posts to. That means a server, a domain, a signing-secret check — for an agent script that runs for four minutes on somebody's laptop, an absurd amount of machinery.
This package uses Socket Mode instead. The app opens a WebSocket to Slack itself with an app-level token, and clicks arrive down that connection. Only outbound traffic, exactly like Telegram's long poll — which is why both adapters end up the same shape.
The connection is opened when the first approval is posted and closed after the last one settles. Nothing is held open while your script is doing anything else.
- Create the app. api.slack.com/apps → Create New App → From scratch. Pick a name and the workspace.
- Turn on Socket Mode. Settings → Socket Mode → enable. Slack offers to
create an app-level token on the spot; give it the
connections:writescope and copy it. It starts withxapp-. This isappToken. - Give the bot its scopes. Features → OAuth & Permissions → Bot Token
Scopes: add
chat:writeandfiles:write. Then Install to Workspace and copy the Bot User OAuth Token. It starts withxoxb-. This isbotToken. - Turn on interactivity. Features → Interactivity & Shortcuts → enable. With Socket Mode on there is no Request URL field to fill in — that is the point. Buttons do not work if this is off.
- Invite the bot. In the channel you want approvals in:
/invite @your-app. Then copy the channel id (channel name → About, at the bottom,C…). This ischannel;#nameworks too.
No webhook, no public URL, no ngrok.
An approval is two messages, one after the other. First the request: the
exact step as a big header, the reason under it, and Deny and Approve at
the bottom. Then the page as the agent left it, as its own message directly
under the buttons:
Submit $12,430 vendor payment to Acme GmbH
The agent may not move money without a human
[ Deny ] [ Approve ]
What the agent sees right now.
[screenshot]
One click settles the raiseHand call that is waiting. The buttons then
disappear from the request and a line takes their place:
Submit $12,430 vendor payment to Acme GmbH
The agent may not move money without a human
Approved by @simon
The screenshot stays where it is. It is the record of what was approved.
Why two messages. A Slack image block can only show a file that has been
shared into that same channel — and sharing a file is posting it as a
message. So a single message with the screenshot inside it is not something
Slack offers: you either share the file and get its message, or you keep the
file private and Slack refuses the whole request with invalid_blocks. The
first live run of this package hit exactly that and a human approved a payment
without ever seeing the page. Two messages, one screenshot, no way to miss it.
Neither button is coloured. Slack's green primary and red danger both read
as "this is the one", and on an approval the product has no opinion about
which answer is right — the human does. Deny sits first, nearest the cursor,
because it is the safe answer.
If the phone got there first, the click says so instead — "Already decided elsewhere — this click changed nothing" — and nothing is overturned. handraise's first answer wins, whoever gives it.
A takeover is a message with the reason and the handoff link, because a takeover cannot be answered from a chat: the human has to drive the browser, and only the handoff page can do that.
| Option | Type | Default | |
|---|---|---|---|
botToken |
string |
required | xoxb-…, with chat:write and files:write. A credential. |
appToken |
string |
required | xapp-…, with connections:write. Opens the Socket Mode connection, and nothing else. |
channel |
string |
required | Channel id (C0123456789). #name works too, but only for public channels — Slack does not resolve a name to a private channel. Use the id. The bot has to be in the channel either way. |
maxWaitMs |
number |
6 min | How long to keep an approval open. Must be positive and finite, at most 24 h. See below. |
baseUrl |
string |
https://slack.com/api |
The Web API root. |
onWarn |
({ code, message }) => void |
— | Called for every failure this package swallows. Neither token ever appears in message, and nothing is printed without it. The code vocabulary is stable and listed below. |
code is public API — matchable, stable across patch releases, and exported as
the WarningCode union so a switch over it can be exhaustive. There are
sixteen:
| code | when | what it means for the handoff |
|---|---|---|
upload_skipped |
the screenshot was empty | the request is posted without it |
upload_failed |
Slack refused the screenshot upload | the request is posted without it |
post_uncertain |
the handoff ended while the request was being posted | the message may or may not exist; if it does, its buttons were not removed |
settled_early |
the handoff ended while the screenshot was still going up | not a failure — the phone answered first; nothing was posted, or the request stands without its screenshot |
warn_threw |
your own onWarn threw |
no further warnings are delivered |
share_failed |
the screenshot could not be shared into the channel | the request stands, with no screenshot under it |
post_failed |
the request could not be posted at all | the handoff fails; the message names the fix |
close_failed |
the edit that removes the buttons was refused | the answer stands; the message may still show buttons |
press_ignored |
a click matched no open approval | nothing; the click is acknowledged and dropped |
socket_reconnecting |
Slack asked for a new connection | nothing; the replacement is opened first |
socket_idle |
nothing arrived for a minute | nothing; the connection is reopened |
socket_error |
the WebSocket reported an error | nothing on its own; a close follows if it was fatal |
socket_stopped |
a permanent failure ended every open approval | every open approval fails, once each |
connect_failed |
one connection attempt failed | nothing; it is retried with a backoff |
frame_unreadable |
a frame from Slack was not JSON | nothing; the frame is dropped |
socket_supervisor_failed |
a bug in this package | should never appear; please open an issue |
It closes when the handoff does. handraise 0.5.1 hands every channel a
settled promise, and this package waits on it alongside the buttons. So an
approval answered on the phone, or one that times out, or one whose browser
session dies, closes here immediately: the buttons come off and the message
says which of those happened — "Decided on the phone: approved.", "Timed
out — nobody answered in time.", "The browser session ended before this was
answered." Nothing keeps the connection up, and nothing keeps your process
alive after raiseHand has returned.
maxWaitMs is only a backstop. It bounds the wait for a handraise that
somehow never settles, which should not happen. It matters in one case: if you
raise timeoutMs on raiseHand past 6 minutes, raise maxWaitMs to match, or
the buttons go away while the agent is still waiting.
Reconnects are handled, and they are routine. Slack asks clients to reopen the connection roughly hourly, and whenever it rebalances. When it does, this package opens the replacement before it drops the old one, so a click cannot fall into the gap. A connection that simply dies — a closed laptop, a NAT timeout — is reopened with a backoff, and a connection that has said nothing for a minute is treated as dead rather than trusted.
One process per app token. Slack lets an app hold several Socket Mode connections at once — that is how its own reconnect advice works — and it distributes an app's events across them without promising which connection gets what. A connection that is handed a click it does not recognise still acknowledges the envelope, so Slack does not redeliver it and the approval that was waiting for it waits until it times out.
Inside one process this is handled for you. Every slack({ … }) with the
same app token shares one connection and one dispatch table, so any number of
channels and any number of open approvals are safe. Build the channel once and
reuse it if you like; building several is fine too.
Across processes it is not fixable from here. Two processes on one app token will lose each other's clicks. Give a second process its own Slack app.
Permanent failures stop at once. A revoked token, a missing scope, a
channel the bot was never invited to, the two tokens swapped round — none of
those get better by being retried for six minutes. The wait ends immediately,
the buttons come off, and onWarn gets a sentence that names the fix.
A screenshot that cannot be shown costs the picture, not the approval. The
request is posted first and the screenshot is shared after it, so a missing
files:write, an unreachable upload host or an empty buffer costs you the
picture and nothing else — the step, the reason and the two buttons are all
still there, and onWarn says what happened (upload_failed, share_failed).
An approval that silently fails to arrive is the worst outcome this package
has.
That ordering is also why channel may be a #name at all: sharing a file
needs a channel id, and posting the request is what resolves the name into
one.
- Both tokens are credentials. They travel in an
Authorizationheader rather than in a URL, which is already better than most chat APIs — but every error this package throws is redacted anyway, becauseString(error)on a failedfetchcan carry the request with it. Do not paste a token into an issue; if you have, rotate it (app-level tokens: Basic Information → App-Level Tokens; bot token: OAuth & Permissions → Reinstall). - A click is authorized twice. A button's
valuecomes back verbatim and Slack does not sign it per button, so it is a claim, not a credential. Each approval carries a fresh 96-bit random nonce, and the click has to come from the exact channel and message this package posted — which Slack fills in itself and a stranger cannot forge. Knowing a handoff id, or finding an old button, is not enough to answer anything. - Anyone who can see the message can decide. A channel means every member can click Approve, and Slack's own tools decide who is in that channel. Use a private channel whose membership is your approver list.
- The takeover link is a bearer credential. Whoever opens it drives a live browser session carrying the agent's cookies. It dies with the handoff, and the message says as much — but a channel is a durable, searchable log, so post takeover links only where you would post a password. Slack is told not to unfurl it, so it is never fetched by Slack's own servers.
- The agent's words are not markup.
reasonandactioncome from an agent, and an agent's words come from a web page — so they are escaped and sentverbatim, withmrkdwnoff on the message text. Without that, a<!channel>in a reason notifies the whole workspace and<https://evil.example|Approve>renders as a link labelled Approve directly above the real buttons. - The screenshot is the page. It can contain an account number, an address, a half-filled form. It is uploaded to Slack's file storage and it stays there; that is the trade for deciding from a phone. Approval mode sends one frame and no live view, which bounds what can ever leave.
bun run test # everything, against a local fake Slack (HTTP + WebSocket)
bun run dist:smoke # loads the built artifact under node
bun run test:live # one real message, waits for a real clickbun run test starts an actual HTTP server and an actual WebSocket server
that answer like Slack — the Socket Mode handshake, the three-second ack
deadline, the reconnect Slack asks for, a socket that simply dies, the two-step
file upload, Block Kit's limits, and a strict rule about which token opens what
— because a mock of Slack would only prove the mock. test:live needs
SLACK_BOT_TOKEN, SLACK_APP_TOKEN and SLACK_CHANNEL, and skips with
step-by-step instructions when they are missing.
This package needs handraise ≥ 0.5.1, the release that added channels.
handraise is a peerDependency (>=0.5.1): nothing of it is imported at
runtime, and a second nested copy would put two HandoffChannel declarations
in one program. Install both:
npm install handraise handraise-slackhandraise-telegram — the same thing in Telegram, with zero dependencies and a two-minute setup. Telegram is the faster one to try; Slack is the one your company already has.
MIT © Simon Doba