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.

substructure.toml
[slack]
dm = "support"
mentions = "support"
subs apply
subs slack connect

subs 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

substructure.toml
[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
WhereAnswered by
A DMdm
A channel with a [slack.channel.<id>]its own agent, or nobody when off
A mention in any other channelmentions

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

SlackEngine
A thread, in a channel or a DMSession slack:{channel}:{thread_ts}
A mention or a DM messageOne turn
A task cardA tool call or a sub-agent 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 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": [ ... ] }
    }
  }
}
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 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