Skip to content

Repository files navigation

handraise-slack

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-slack

Not 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 phone

That 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.

No request URL, no tunnel, no hosted callback

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.

Setup, in five minutes

  1. Create the app. api.slack.com/appsCreate New AppFrom scratch. Pick a name and the workspace.
  2. Turn on Socket Mode. Settings → Socket Mode → enable. Slack offers to create an app-level token on the spot; give it the connections:write scope and copy it. It starts with xapp-. This is appToken.
  3. Give the bot its scopes. Features → OAuth & Permissions → Bot Token Scopes: add chat:write and files:write. Then Install to Workspace and copy the Bot User OAuth Token. It starts with xoxb-. This is botToken.
  4. 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.
  5. 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 is channel; #name works too.

No webhook, no public URL, no ngrok.

What the human sees

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.

Options

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.

Warnings

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

When an approval closes

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.

Security

  • Both tokens are credentials. They travel in an Authorization header rather than in a URL, which is already better than most chat APIs — but every error this package throws is redacted anyway, because String(error) on a failed fetch can 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 value comes 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. reason and action come from an agent, and an agent's words come from a web page — so they are escaped and sent verbatim, with mrkdwn off 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.

Running the tests

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 click

bun 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.

Version note

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-slack

See also

handraise-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.

License

MIT © Simon Doba

About

Slack channel for handraise: an agent's approval arrives as a screenshot with Approve/Deny in a Slack channel, over Socket Mode — no public endpoint.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages