openstation

Connectors

Slack

Connect a Slack app to your workspace.

Puts the agent in a Slack workspace over Socket Mode — an outbound WebSocket, so no public URL and no inbound webhook to host.

Set up the app

  1. Create an app at api.slack.com/apps.
  2. Enable Socket Mode. That produces an app-level token (xapp-…) → SLACK_APP_TOKEN.
  3. Under OAuth & Permissions, add the bot scopes the connector uses:
    • chat:write — post replies
    • reactions:write — the 👀 acknowledgement on receipt
    • channels:history, groups:history, im:history — read the messages it's meant to answer
    • app_mentions:read — if you want it reachable by mention
  4. Under Event Subscriptions, subscribe to the message events matching those scopes (message.channels, message.groups, message.im).
  5. Install to the workspace. The bot token (xoxb-…) → SLACK_BOT_TOKEN.
  6. Invite the bot to the channels it should answer in.

Run it

export SLACK_BOT_TOKEN=xoxb-...
export SLACK_APP_TOKEN=xapp-...
bun packages/cli/src/index.ts my-space serve

serve starts Slack because both tokens are present. One alone won't do — it would build a client that fails at connect, so the detection requires the pair.

Naming it instead

The variables above are the default detection: one Slack app per process. To run two — a work app and a customer app, or a production app and a test app — declare them, with credentials by reference:

connections:
  work-slack:
    type: slack
    botToken: ${WORK_SLACK_BOT_TOKEN}
    appToken: ${WORK_SLACK_APP_TOKEN}
  test-slack:
    type: slack
    botToken: ${TEST_SLACK_BOT_TOKEN}
    appToken: ${TEST_SLACK_APP_TOKEN}

A literal token there is a load-time error. With the block present it is the whole truth — ambient SLACK_BOT_TOKEN adds nothing. See connections:.

Behavior

Threads are conversations. The conversation key is slack:<channel>:<thread-ts>; a thread reply shares its parent's key, and a top-level message keys on its own timestamp. Session continuity follows that, so a thread stays continuous and a new top-level message starts fresh. The slack: prefix is what lets an unprompted deliver: route a key back to the connector that minted it. It was added on 2026-07-30: a key of the old <channel>:<thread-ts> shape no longer matches, so sessions and origin: frontmatter written before then start fresh.

Replies land in-thread, chunked at 3900 characters.

Markdown is translated to mrkdwn on the way out — bold, strikethrough, headings and links, with horizontal rules dropped and code left verbatim. Slack renders GitHub Markdown literally, and Claude writes it however it is prompted, so the connector translates rather than relying on the charter.

A 👀 reaction goes on the message when it's received, so a user can see the turn started before the reply arrives. Pick a different emoji, or switch the ack off, with ack: on the connection — see connections::

connections:
  work-slack:
    type: slack
    botToken: ${SLACK_BOT_TOKEN}
    appToken: ${SLACK_APP_TOKEN}
    ack: hourglass_flowing_sand   # bare name, no colons; `none` switches it off

A message the platform refuses is never reacted to, because the reaction means "I will answer this". A reaction that fails is logged and the turn continues.

Ignored on purpose: bot messages, any message subtype (edits, joins, deletions), messages with no attributable user, and messages that are empty after @-mentions are stripped.

Buttons degrade to text, label and id both — - Approve → reply "approve:a1b2" — because the id is what has to be sent to choose the option. That holds for an approval prompt and for any options the agent offers in a ```send-buttons block (authoring them).

Duplicate delivery

A slow turn can outlast Slack's retry window, so Slack re-delivers the event. The connector remembers event ids for a TTL window and drops repeats.

That memory is in-process — restart the daemon and a redelivered event will be handled again.

Attachments

A shared file is downloaded into the workspace's okf/scratch/inbound/ and named in the prompt by its local path:

[attachment: quarterly.pdf (saved to /ws/okf/scratch/inbound/F123-quarterly.pdf)]

The agent opens it with its ordinary file tools. Slack's own url_private URL is never handed over — reading it needs the bot token, which the agent does not have. The file id prefixes the name, so two uploads called screenshot.png don't overwrite each other.

okf/scratch/inbound/ is gitignored, so inbound files are not committed. If a download fails the pointer falls back to the URL and the message is still answered — the user's question does not fail with the file.

Forwarded messages are folded in too. Slack carries a forward as an attachment whose body is absent from the message text, so a forwarded thread with "what happened here?" arrives as both:

what happened here?

[Forwarded Slack message from #product-eng]
the deploy failed at 3am

Outbound files work as well: the agent names them in a fenced send-files block (one workspace-relative path per line) and they are uploaded into the thread. Paths must stay inside the workspace — anything outside it, missing, or beyond the 30-file cap is dropped and logged. See Telegram for the block's shape.

What's verified

The Socket Mode handshake has been verified against a real Slack app. Beyond that, the connector's logic — normalization, dedup, chunking, threading — is covered by the shared channel contract suite against fakes. No agent has yet run a full production workload on a live Slack channel.

Notes for a shared workspace

Socket Mode fans events out to every open connection using the same app token. Two processes with the same credentials both receive every message and both answer. Use a separate app (and separate tokens) for development, or stop the other process first.

One agent per process

serve passes no channel to agent selection, so the default: true agent answers everything regardless of channels:. To run two agents in one Slack workspace, run two processes with different -a values.

View Markdown source on GitHub ↗