Slack
What works in the Slack integration today, and what does not exist yet
When a session waits on a person (a tool permission, a question, a push approval), a card appears in its thread and can be settled in Slack. See Approvals and answers.
| What exists today | API | Who |
|---|---|---|
| Link my Slack account to my web account | POST /v1/linked-accounts/slack/start, …/callback, GET /v1/linked-accounts, DELETE /v1/linked-accounts/{id} | The signed-in user, for themselves |
| Connect a Slack app (bot) to an agent | POST·GET /v1/agents/{id}/identities, DELETE …/identities/{identity_id} | owner, admin, and that agent's administrator |
| Allow guests to use agents | POST /v1/workspaces/{id}/slack-guest | owner, admin |
All three are called only with the web login cookie. An API key gets 403. The examples below use the WEB header and WS from Create.
Enable guest use
This decides whether Slack guests (multi-channel and single-channel guests) can use agents. It is off by default.
WS=$(curl -sS "$API/v1/auth/me" "${WEB[@]}" | jq -r .workspace.id)
curl -sS "$API/v1/workspaces/$WS/slack-guest" "${WEB[@]}" \
-d '{"slack_guest_enabled": true}' | jq .{
"slack_guest_enabled": true
}Even when it is on, a guest has to link their own Slack account before using an agent. Users from another company who join through Slack Connect cannot use agents regardless of this setting.
Link a Slack account
The link uses Sign in with Slack (OpenID Connect). The operator sets CONSOLE_ORIGIN, the console's public address; a workspace administrator adds an App Configuration Token under Settings > Workspace > Integrations > Slack so the account-link app can be made; and that workspace must have a confirmed Slack installation. The local stack has no CONSOLE_ORIGIN, so the start request returns 503.
curl -sS -o /dev/null -w '%{http_code}\n' -X POST "$API/v1/linked-accounts/slack/start" "${WEB[@]}"503With a configured installation, the start request returns an authorize_url. After you grant consent on Slack's authorization screen at that URL, the web link screen passes code and state to the callback and the link is complete. The link state is active, stale, or revoked.
The link response and each item of GET /v1/linked-accounts also carry the names Slack gave at linking: external_user_name is the Slack display name (the id_token's name), and external_tenant_name is the Slack workspace name (https://slack.com/team_name). Linking again replaces them. A link made before names were stored, or one Slack gave no name for, has null, and the web screen shows the ID instead. No Slack token is stored, so expires_at, the provider token's expiry, is always null for a Slack link. The expires_at of the start response is when that link request lapses, a different value.
| Response | Meaning |
|---|---|
409 SLACK_NOT_INSTALLED | This workspace has no confirmed Slack installation |
409 LINK_TEAM_MISMATCH | The account belongs to a Slack team that is not installed |
409 LINK_TAKEN | That Slack account is already linked to someone else |
409 LINK_EXISTS | I already have a link for the same team |
Notice language
The words the platform writes in Slack (refusal notices, failure and waiting-for-an-answer notices, the Open on the web link, and the (Web console) label on instructions sent from the web or the API) follow the language a user picks under Language & theme in the web console (한국어, 日本語 or English). The console saves the chosen language on the server (PATCH /v1/auth/me). With none saved, a console that opens in a language other than English (from the browser's language, for example) saves that one, so the console and Slack agree.
- A refusal notice only one person sees is written in the language of the user that Slack account is linked to.
- Notices and labels posted in a thread are written in the language of the user who started the session. Everyone in the thread reads them, so a thread stays in one language.
- A user who never saved a language, and a Slack account that is not linked, get English.
Model answers and what users type are not translated. The deployment welcome has no single reader, so it is sent in Korean.
Approvals and answers
When a session started from a Slack thread waits on a person, each request it waits on gets one card in that thread. The card mentions whoever started the session and shows a link to the same request on the web and when it expires. A card is written in the language of whoever started the session (Notice language) when it is posted, and keeps that language once settled.
A tool permission card sums up what the agent wants to do in one line, such as Run a command: bun test, Edit a file: src/app.ts or Open a PR: title (branch). A tool that writes or edits a file also shows the first three lines of what it writes under the summary. The raw input the model passed is folded under Raw input and shows only when expanded. It shows each field as name: value, with line breaks inside a value shown as real line breaks rather than \n. A long raw input is cut; Open on the web shows all of it. A push approval card shows the repository, branch and commit first and folds the rest under Details. Text the model wrote never becomes a mention or a link anywhere on the card.
| Request | What the card offers |
|---|---|
| Tool permission | Allow and Deny. When the engine proposed a rule it keeps asking about, Allow for session is added. The rule is shown on the card and in a confirm dialog. The button is left out for a Bash call with a redirect, a pipe or several commands, where the proposed rule may not match that call. Once pressed, calls that match the rule are not asked again until the session ends. A settled card reads Allowed for session. A push approval and the server allowlist are not changed by this button |
| Question | Option buttons for a single question with five options or fewer, plus Type an answer when free text is allowed. Otherwise Answer opens a form. More than 10 questions, or more than 100 options in one question, do not fit a Slack form, so the card says "Please answer this request on the web" with an Open on the web button |
| Push approval | Repository, branch and commit, with Approve (asks once more) and Reject |
The person who presses must have their Slack account linked to a web account (Link a Slack account) and must be someone who may answer that session's requests on the web, judged by the same rules. An unlinked account gets the link page, and someone without the right gets a refusal; both are shown to that person only, and nothing is settled.
Once settled, the card loses its buttons and keeps the result, whether it was pressed in Slack or answered on the web. When a request is answered on the web, expires, or closes with its session, the action-broker inside control-host edits the Slack card into the same shape shortly after. The first line is the result and who settled it, with where it was settled: Allowed · @name when settled in Slack, Allowed on the web · @name when settled on the web. When whoever settled it has no linked Slack account, the line shows the result alone. A tool permission or push card becomes one line: that result, the request's summary and the web link. A question card keeps the full question and shows the answer chosen below it as Chose <option> (Chose <option> on the web when answered on the web). A typed answer shows only as a typed answer, never its text, since the whole thread reads the card. A request that expired or closed with its session mentions no one. A question card keeps its question and shows Expired when it expired, or Can no longer be answered when its session closed. When Slack refuses the edit, it is tried again shortly after; when the message was deleted or the channel archived, the card is left as it is. The card is drawn from the stored request, not from the message pressed. Pressing a request that the web or someone else already settled does not settle it again; the card changes to the current result. A push approved or rejected in Slack is followed by an instruction telling the session, so it opens the pull request or reports the rejection. If a turn of the session is running when the push is approved, the instruction waits for that turn to end and is dropped if that turn already opened the pull request; it is also dropped when the branch already has one. The session does not open the same pull request or give the same answer twice.
Buttons and forms arrive at the agent app's Interactivity address, /v1/slack/interactions/{agent_id}. As with events, each request is checked against the app's signing secret and a timestamp within 300 seconds. Installation finalize puts the address in the manifest along with the Event Subscriptions address. For an app created before this feature, Recheck (POST /v1/agents/{id}/slack/app/events/recheck) sends the manifest again and registers it.
Original Slack message link
An instruction started or sent from Slack (a mention, a DM or a thread reply) keeps where that Slack message is (team, channel, ts, and thread_ts for a reply) together with the link Slack gave for it. The link comes from chat.getPermalink, which needs no extra scope. The web session screen shows a View in Slack link next to who started the session and from where in the header, and on each Slack instruction in the conversation; it opens in a new tab. Through the API, read the session's slack_permalink (the message that started the session) and each turn's slack_permalink (the message that sent that instruction).
Private channels and DMs keep only the link too; no more of the message is stored (the text received as an instruction stays in the turn's message as before). The link opens only for people who can read that message in Slack. Sessions and instructions from before links were kept, and an instruction Slack gave no link for, read null and show no link.
Register a Slack installation
This is the operator's terminal path. Given a Slack app installed in a Slack workspace, register the installation with that app's bot token. A console flow that creates and installs the Slack app for you comes in a later version. The CLI asks Slack (auth.test, bots.info) for the team, the bot user, and the app, and records a confirmed (verified) installation. The app needs the bot scope users:read. Pass the token only in the SLACK_BOT_TOKEN environment variable. The CLI never takes the token as an argument and never prints it.
read -rs SLACK_BOT_TOKEN && export SLACK_BOT_TOKEN
bun run slack-install register --workspace "$WS" --app-id A0123ABCD
unset SLACK_BOT_TOKEN{"outcome":"verified","registration":"registered","installation_id":"…","workspace_id":"…","app_id":"A0123ABCD","team_id":"T0123ABCD","bot_user_id":"U0123ABCD"}Running the same command again re-confirms the same installation (registration: existing). On failure it writes one line to stderr and exits with one of these codes.
| Exit code | Meaning |
|---|---|
| 0 | A confirmed installation exists |
| 2 | Bad arguments or environment variables |
| 3 | Slack refused the token or is unreachable |
| 4 | That app and team belong to another workspace (workspace_conflict). Even if it was revoked there, only that workspace can register it again |
| 5 | The installation was revoked while registering (revoked). Running again reopens it |
| 6 | The workspace does not exist (workspace_not_found) |
| 7 | The token belongs to an app other than --app-id (app_mismatch) |
| 1 | Any other error |
An agent's Slack app
Read app settings
Agent administrators, including workspace owners and admins, call these endpoints with a web session. GET /v1/agents/{id}/slack/app includes event_url and events. The absolute event URL is the https address the server computes from the console's public address (CONSOLE_ORIGIN) plus /v1/slack/events/{agent_id}; a server whose events enter apart from the console uses that entrance's address (SLACK_EVENTS_PUBLIC_URL) instead. It is null when none can be computed. events.state is pending, verified, or failed. It turns pending when the install finalize puts this address in the manifest as the Request URL, and events.url keeps the address sent (null before any). Sending the same address again to an app that is already verified keeps it verified; a changed address turns it pending again. It turns verified when the first request signed with that app's signing secret (url_verification or an event) arrives. A url_verification gets its challenge back, and nothing else, once its signature and timestamp check out. A failed manifest registration turns it failed with a safe error code. checked_at is when the state last changed, or null.
Still pending after finalize means Slack has not reached the address yet. Public DNS may not resolve it yet, or Slack may not have checked a URL set through the manifest. POST /v1/agents/{id}/slack/app/events/recheck (Recheck under Addresses for a Slack app in the console's Slack tab) sends the manifest with its Request URL again. If it stays pending, press Verify (or Retry) for the Request URL on the Event Subscriptions page of the Slack app's settings. Once the address has been sent, the console's Slack tab shows the same notice with a link to that page while it is pending. On a local stack opened with scripts/slack-funnel.sh on, wait until its last line, a public DNS check (dns.google, cloudflare-dns.com), no longer warns before you create the Slack app.
GET /v1/agents/{id}/slack/app/manifest returns manifest_yaml, generated from the current agent and server configuration without secret values.
GET /v1/agents/{id}/slack-channels?ids=C1,C2 resolves at most 50 IDs. Each item has id, name, and state; only IDs Slack reports as missing or inaccessible have name and state unknown. Transport failures and upstream errors return 503 for the request without caching; a revoked or invalid bot token returns 409 SLACK_NOT_INSTALLED. There is no cursor or full scan. Results are cached per app and channel for 60 seconds. Slack 429 returns 503 with Retry-After.
GET and PUT /v1/agents/{id}/slack/app/settings read and save { "mention_only": true }. With true the agent responds only when @mentioned; with false it responds to posts in the channel without a mention too. With either value, a reply in a thread the agent is already talking in (a thread linked to a session) continues that session without a mention. It applies to every channel in use, the ones already connected included, and not to DMs. The initial value is true, and settings can be saved before app creation. There is no app-wide on/off switch.
A binding's mode is off (not used in this channel) or on (in use). When mention_only is false, a person's top-level message in a channel in use opens a session without a mention. A reply in a thread the agent is not part of gets no response.
Each agent has its own Slack app. To connect it, send the app id, the team id, and the name of a credential reference that holds the bot token (credential_ref, not the value).
curl -sS "$API/v1/agents/$AID/identities" "${WEB[@]}" \
-d '{"provider": "slack_bot", "app_id": "A0APP", "team_id": "T0TEAM", "credential_ref": "slack/research-bot-token"}'Without a confirmed Slack installation this returns 409 SLACK_NOT_INSTALLED. The operator creates an installation with the registration CLI above.
Removing the app in Slack disconnects the agent. An app_uninstalled event, or a tokens_revoked event that names a bot token, marks the installation revoked and revokes the agent's active link to it too (revoked_at is set). The link's channel bindings and credential name are released with it. The console's Slack tab shows "Reinstall required" instead of "Connected". The same event arriving again, or arriving late after a reinstall, changes nothing. Installing again creates a new active link; the old one stays as a revoked record.
Some removals send no event. When a request that calls Slack with the bot token (channel list, name lookup, channel binding) is refused with token_revoked or account_inactive, the same disconnect happens. The answer is still 409 SLACK_NOT_INSTALLED. Errors that removal is not the only cause of (invalid_auth, not_authed, token_expired), 429, 5xx and network failures leave the state alone.
Channel joins and the deployment welcome
When you save one of the agent's channels (PUT /v1/agents/{id}/slack-bindings), the server puts the bot in that channel. The bot joins a public channel itself. Slack does not let a bot join a private channel, so invite it there with /invite @<bot>; the server notices the invite and carries on. The binding's membership.state shows the result. Inviting the bot to a channel you never saved also connects that channel, in use (on). A channel you already saved keeps its settings, so one you switched off stays off.
Everyone in a channel reads what the agent posts there, including people outside the responsible team and people a workspace setting keeps from seeing that session in the console. The console's channel section says so too.
membership.state | Meaning |
|---|---|
joined | The bot is in the channel |
awaiting_invite | A private channel, waiting for the invite |
config_error | Slack refused the token or a scope (error holds Slack's code). Reinstall the app |
join_failed | The channel is archived or a workspace policy refused the join (error) |
When a release is first deployed, the home channel gets 안녕하세요, <agent name>입니다. 이 채널에 연결됐어요. 멘션하면 답할게요 (Korean by default). Each channel hears it once per agent. Deploying a new release, submitting the same definition again, rolling back to an earlier release, or disconnecting Slack and connecting it again posts nothing in a channel that already heard it. Moving the home to a channel that has not heard it yet posts once there. If the bot is not in the home yet, the message waits until it is. A welcome or join that fails never fails the deployment.