Slack

Updated Sep 2, 2026View as Markdown

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 up the bot

Each agent gets its own Slack app. Declare it, create it in Slack, then give the deployment the two values Slack hands back.

subs.toml
[agent.support.slack]
name = "Support"
subs apply
subs auth agent.support.slack

subs auth prints an app manifest. Go to api.slack.com/apps, choose Create New App, then From a manifest, and paste it in. Create the app and install it to your workspace.

Slack then shows you two values. Paste them back when the command asks for them.

ValueWhere Slack shows it
Bot User OAuth Token, starting xoxb-OAuth & Permissions
Signing SecretBasic Information

The bot answers as soon as both are in. Run subs list to see which agents still need theirs.

To replace the two values later, run subs auth again. To remove them, run subs revoke agent.support.slack. The app itself stays in Slack, and the same manifest still works, so you can put new values in without creating it again.

An engine you run yourself uses the same block and a different pair of values. See Self-hosting.

Where the bot answers

Invite the app to a channel and it answers mentions there. Remove it and it stops. There is no channel list in subs.toml.

DMs work as soon as the app is installed.

To keep an agent to one or the other, set answers.

subs.toml
[agent.support.slack]
name = "Support"
answers = "dm"
ValueThe bot answers
bothDMs, and mentions in any channel it is in. This is the default.
dmDMs only.
channelsMentions only.

answers decides which permissions the app asks Slack for. An app set to dm is never given permission to read channels, so a mention does not reach it even if someone invites it to one.

Changing answers changes the manifest. Apply the file, then update the app in Slack from the new manifest that subs auth prints.

Naming the app

subs.toml
[agent.support.slack]
name = "Support"
description = "Answers customer questions"

name is what the bot is called in Slack, up to 35 characters. Without it the agent ID is used. description is the app's About text, up to 140 characters.

Both come from the manifest, so changing them means updating the app in Slack.

Several bots in one workspace

Every agent with an [agent.<id>.slack] block is a separate Slack app, with its own name, icon, and DMs. Run subs auth once for each.

subs.toml
[agent.support.slack]
name = "Support"

[agent.oncall.slack]
name = "On-call"

Two of your bots can sit in the same channel. Each answers only when it is mentioned by name, and each keeps its own conversation in a thread.

How Slack maps to the engine

SlackEngine
A thread, in a channel or a DMSession slack:{agent}:{channel}:{thread_ts}
A mention or a DM messageOne turn
A task cardA tool call or a subagent run
A replyThe 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 subagent run shows as a task card. Cards are collapsed by default. After 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 answers the question. The prompt then loses its buttons and says who answered and which option they picked. A danger button records a decline. Any other button records an approval. Work after that goes into a new message.

A click cannot send anything that the prompt did not offer, and a second click changes nothing.

For the worker side, what a click resumes with and how to answer one yourself, see Interrupts and the node-hono-tool-approval example.

Send a message while a prompt is open

The engine refuses a message while the session is paused, because the prompt needs an answer first. The bot posts the open question again instead of staying silent, so any link in the question comes back with it.

Authorize a connection

The bot raises a prompt of its own when a connection has no usable credential. It names the connection, says what happened to its credential, and links to the page where a person authorizes it. See Connectors.

The link appears when the file has a [remote] pinned to a project on the hosted cloud, which is the only deployment whose dashboard address follows from its API address. Anywhere else the prompt names subs auth <path>, which is what an operator runs on the machine where the engine runs.

Retry fetches the connection's tools again, and the turn continues with them. If the credential is still not good, the same question comes back. The engine raises one prompt per connection however many times a decision runs, so nobody is asked twice.

Customize 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": [ ... ] }
    }
  }
}
KeySets
statusThe thread's working indicator.
viewThe whole state of the turn's message. It streams during the turn and becomes the final message.
promptThe message an interrupt posts, when the same decision raises one.
updateRewrites one message, by channel and ts.

On every decision the engine proposes the default view. A worker that customizes the view changes the proposed view instead of building a new one.

A view streams while the turn runs. The turn.finished view becomes the final message, posted by the reply that ends the turn.

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 might be missing.

Attachments

A file uploaded with a message reaches the agent. The bot downloads it with the bot token and stores it in the database. The prompt carries a reference, and the bytes go to the model at the call. This needs files:read.

What the model receives depends on the type.

TypeSize limitThe model receives
PNG, JPEG, GIF, WebP5 MBAn image
PDF10 MBA document
Text: CSV, JSON, Markdown, code, logs1 MBA file
Audio10 MBAudio
Video20 MBVideo
Anything elsenoneA note naming the file

An image the agent produces goes back the other way. The bot uploads it to Slack once per workspace and the reply embeds it. This needs files:write. An image that Slack refuses becomes a note in the reply. The text always arrives.

Point 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 steps