Slack
Mention the bot in a channel and it answers in the thread. DM it and it answers every message.
The thread is the session. Later mentions continue the conversation.
Set it up
Two steps. Say who answers, then connect the workspace.
[slack]
dm = "support"
mentions = "support"subs apply
subs slack connectsubs slack connect opens Slack's consent page and installs the app for you.
The token goes to the deployment. The command waits for the workspace, then
prints its name.
You need both steps. A connected workspace with no [slack] is a bot that never
answers.
A Slack app installs once per workspace. Run the command again and it refreshes the token.
An engine you run needs a Slack app you own. See Self-hosting.
Where the bot answers
[slack]
dm = "support" # direct messages
mentions = "support" # @mentions, in any channel not named below
[slack.channel.C0ENGOPS] # eng-oncall
agent = "oncall"
[slack.channel.C0RANDOM] # random
off = true| Where | Answered by |
|---|---|
| A DM | dm |
A channel with a [slack.channel.<id>] | its own agent, or nobody when off |
| A mention in any other channel | mentions |
Each key defaults to off. The bot answers only where you tell it to.
Leave mentions off and the channel table becomes an allowlist. Invite the bot
anywhere and it answers only in the channels you named.
DMs have their own key. An allowlist does not stop them.
A channel names an agent, not a prompt or a tool list.
[agent.<id>] already holds those. Point a channel at another
agent and you change its prompt, its model, and its tools together.
Name a channel by id, never by name. Get the id from the channel's About
tab, from the channel link (…/C0ENGOPS), or from conversations.list. A
#name is a parse error. The engine checks every agent against the file's
sections at startup.
How Slack maps to the engine
| Slack | Engine |
|---|---|
| A thread, in a channel or a DM | Session slack:{channel}:{thread_ts} |
| A mention or a DM message | One turn |
| A task card | A tool call or a sub-agent run |
| A reply | The turn's result |
A turn is one Slack message. The message opens with the first task card. More cards stream in as the turn makes tool calls. The result completes the message.
A thread shows one open message at a time. A queued turn waits for the turn ahead of it.
Each tool call and sub-agent run shows as a task card. Cards are collapsed by default. Once the turn finishes, each card shows its arguments and its result.
Interrupt prompts
An interrupt with a message in its payload posts that message with buttons.
This is how a worker asks a person something from Slack.
The payload is an AG-UI Interrupt.
Put the spec's fields at the top level. Put everything else under metadata.
{
"message": "Run `send_email`?",
"toolCallId": "tc_1",
"expiresAt": "2026-07-24T18:00:00Z",
"metadata": {
"options": [
{ "label": "Approve", "value": { "decision": "approve" }, "style": "primary" },
{ "label": "Deny", "value": { "decision": "deny" }, "style": "danger" }
]
}
}message is mrkdwn. Each entry in metadata.options becomes a button.
expiresAt shows as a footnote. An interrupt with no message posts
Paused: {reason}.
The engine delivers metadata to AG-UI clients unchanged. Every client can read
it, so keep anything private in worker state.
Put the routing hint in the interrupt's reason: tool_call,
input_required, confirmation, or your own namespaced value.
What a click does
A click resumes the interrupt with the AG-UI resume shape.
{ "status": "resolved",
"payload": { "decision": "approve" },
"responder": { "channel": "slack", "user": "U…", "label": "Approve" } }The inner payload is the chosen option's value. The engine reads it from the
recorded interrupt, so a click cannot send its own value.
Every click is a client.action decision. For a prompt button, the engine
proposes the resolution above as an interrupt.resolve action. A worker that
returns the proposal needs no code.
A worker that wants its own rules answers the decision itself. It can check who clicked or refuse the resolve. Slack delivers a click more than once, so record the clicks you have handled in worker state.
When someone resumes the interrupt, the prompt loses its buttons and shows the result. Work after the resume goes into a new message.
For the worker side, see the
node-hono-tool-approval example.
Customizing what the bot shows
Every Slack message goes through the decision loop. A decision response can
carry a channels.slack value. Without it, the bot shows its default. With it,
your value wins.
{
"messages": [ ... ],
"actions": [ ... ],
"channels": {
"slack": {
"status": "is researching…",
"view": { "text": "fallback text", "blocks": [ ... ] },
"update": { "channel": "C…", "ts": "123.456", "text": "…", "blocks": [ ... ] }
}
}
}| Key | Sets |
|---|---|
status | The thread's working indicator. |
view | The whole state of the turn's message. It streams during the turn and becomes the final message. |
prompt | The message an interrupt posts, when the same decision raises one. |
update | Rewrites one message, by channel and ts. |
On every decision the engine proposes the default view. A worker that
customizes changes the proposed view instead of building a new one.
A view streams while the turn runs. The turn.finished view is the finished
message: the reply that ends the turn posts it.
Blocks with buttons pass through unchanged. Give each button an action_id and
a value, and the click comes back as a client.action decision with both.
A worker that replaces a prompt's blocks must keep the interrupt_id and
option on its buttons, or the click cannot find its interrupt.
This is how you build a custom bot on the default behavior. Add buttons to a
turn.finished view. When someone clicks one, days later, answer the
client.action with a note and an llm.call. The work opens a new turn.
Thread context
The bot reads what it missed. Messages sent between mentions reach the agent at
the next mention. Users appear as <@U…>: text.
The bot needs channels:history and im:history to do this. Without them it
appends the new message alone, with a note that context may be missing.
Pointing a session at a thread
A session belongs to Slack because of the slack_channel and slack_thread_ts
owner metadata. The shape of the session id does not matter.
An API client can set the same metadata on its first submit. That session's turns then go into the thread like any other.
A click on a reply goes back to the session that posted it. Each reply carries its session id.
Next
- Interrupts: the pause behind a prompt.
- Conversations: the session behind a thread.
- Self-hosting: run the bot with your own Slack app.