Slack

The Slack channel hosts the agent behind a Slack bot. Somebody mentions the bot, a thread opens, and that thread is one conversation for as long as people keep mentioning the bot in it. A question the agent asks is posted as a message with buttons, and it may be answered days later.

Note

The channel is opt-in. The configuration must carry an expose.agent.slack block, and the worker reads SLACK_APP_TOKEN and SLACK_BOT_TOKEN from the environment.

The connection is Slack’s socket mode, so the worker listens on no address and needs no public URL.

Creating the Slack app

Create an app at api.slack.com/apps from this manifest:

display_information:
  name: Fisk AI
features:
  bot_user:
    display_name: fisk-ai
oauth_config:
  scopes:
    bot:
      - app_mentions:read
      - channels:history
      - chat:write
      - groups:history
      - users:read
settings:
  event_subscriptions:
    bot_events:
      - app_mention
  interactivity:
    is_enabled: true
  socket_mode_enabled: true

Socket mode is the transport the channel connects over. app_mention is the only event it subscribes to. Interactivity carries the button presses and the answers typed into a question back, so without it an answer to a question never reaches the worker.

ScopeWhat it covers
app_mentions:readreceiving the mention that opens or continues a thread
chat:writethe status message, the answer, the questions, the notes
channels:historyreading the conversation around a mention in a public channel
groups:historythe same in a private channel
users:readresolving a user id to the name the model and the log see
Warning

Changing an app’s scopes or events does nothing until the app is reinstalled to the workspace. A worker whose bot token predates the change starts, connects, and then fails calls it has no scope for.

The credentials come out of the app:

TokenWhere it comes fromValue
SLACK_APP_TOKENBasic Information, an app-level token with connections:writestarts xapp-
SLACK_BOT_TOKENOAuth and Permissions, the bot user OAuth tokenstarts xoxb-

Neither appears in the configuration file. A missing one fails at startup naming the variable, and a token Slack refuses fails there too: the worker calls auth.test before it accepts anything.

Invite the bot to a channel with /invite @fisk-ai, then mention it.

Starting a worker

identity: helper
application_path: /usr/local/bin/nats
system_prompt: |
  You inspect NATS servers for the people in this Slack workspace. Answer concisely.
include:
  tools:
    - ^stream_
expose:
  agent:
    slack: {}
$ export SLACK_APP_TOKEN=xapp-...
$ export SLACK_BOT_TOKEN=xoxb-...
$ fisk serve --config slack.yaml
Serving helper/1.2.0:

         Endpoints: slack
             Model: claude-sonnet-5
          Sessions: file
         Knowledge: disabled
         Telemetry: disabled
    Tool Directory: /var/lib/fisk-ai
      Tool Timeout: 5m0s

A Slack turn runs the whole agent loop, so identity, system_prompt and llm.model are all required. The waiver that lets a tool-serving configuration omit them does not apply.

What a thread shows

A mention starts a turn, and that turn posts a status message it edits while the run works: :thinking_face: Thinking..., :hammer: Calling tools..., :books: Searching knowledge.... The message names a family of tools rather than the tool being run, because everybody in the channel reads the thread.

Each line opens with an emoji, so a thread scrolled past shows which turns worked and which did not before anybody reads the words:

The turn isThe line reads
waiting for a worker:hourglass_flowing_sand: Queued...
thinking:thinking_face: Thinking...
using the memory tools:brain: Accessing memory...
using the knowledge tools:books: Searching knowledge...
using any other tool:hammer: Calling tools...
waiting on somebody:question: Waiting for an answer...

The emoji is part of the line, so the notification a phone shows carries it too.

The status message carries a Stop button while the turn is running, and anyone in the thread may press it. The bot finishes the step it is on and stops there. Everything the thread has said is kept, so mentioning the bot again carries on from that point rather than starting the conversation over.

The answer is posted as a message of its own, and the status message becomes a link to it. Slack sends no notification for an edit, so a turn that answered by editing its own status message would have pinged somebody with Thinking... and told them nothing.

The answer goes out as markdown for Slack to render. The channel cuts it at 12,000 bytes and ends it with a note where it did not fit. Everything else the channel says is plain text it wrote itself.

no_progress turns the status message off. The answer, the questions and the refusals are posted either way, and the Stop button goes with the status message.

Who is speaking

Every line the model reads is prefixed with the speaker, as their name and the markup that addresses them:

Ana Silva <@U024BE7LH>: the deploy went out at four
Ben Cole <@U0LM3D6TP>: and disk climbed right after

The name comes from the profile: the real name, then the display name, then the handle. It falls back to the user id under all three, and to the id alone where users:read was not granted, which the worker logs as a warning the first time it resolves each person.

The markup is what notifies somebody. Slack sends a notification for <@U024BE7LH> and none for a name written out, so an answer that addresses people by name reaches nobody’s phone. The bot does not use it unless you say so, which is a line in system_prompt:

system_prompt: |
  You inspect NATS servers for the people in this Slack workspace. Answer concisely.

  Each line of the conversation is prefixed with the speaker's name and their Slack id, as
  "Ana Silva <@U024BE7LH>". When you address someone, write that <@...> markup rather than
  their name, so they are notified.

Questions

A tool that needs a person asks in the thread, as a message of its own. It opens by mentioning whoever started the turn, so they are notified, and anybody in the thread may answer whether or not they asked the question.

QuestionThe thread shows
a yes/no questionYes and No
a confirmation gateAllow once, Allow for this conversation, and Decline
a selectionthe options as a numbered list in the message, and a button per number under it
a free-text questiona field to type the answer into, sent by pressing enter

The first three carry Dismiss beside them, which answers the tool that a person was reached and gave no answer. The gate’s Decline is its dismissal: the gated command not running is the whole of what declining a gate can mean.

A selection puts the options in the message rather than on the buttons because a button label is cut at 75 characters, where the message holds 3000. Twenty-five options share those characters, so a long option is cut to its share of them and every option is on the list.

Nothing expires. The question stays in the thread until somebody answers it, and the bot carries on from that answer whenever it arrives: a minute later, on Thursday, or after the worker has been restarted in between. The status message reads :question: Waiting for your answer. in the meantime, and the question message records who answered and what they chose.

answer_grace is how long the bot stays on that thread before it goes back to answering other people. It changes nothing about how long an answer is accepted for.

The worker never sees a plain reply in the thread. Only app_mention is subscribed, so words typed under a question reach it through the question’s own field or through a mention and no other way, and every question message says so. A mention answers a free-text question; while any other kind is open the channel refuses the mention with a link to the question.

Warning

Allow for this conversation on a confirmation-gated command covers the whole thread, not one turn and not the person who pressed it. Anybody who can mention the bot in that thread runs that command from then on.

How a turn ends

The status message says what became of the turn:

EndingThe thread shows
the agent answeredthe answer as its own message, and :white_check_mark: Done: see the answer
the agent answered nothing:white_check_mark: I finished, but had nothing to say.
a question is unanswered:question: Waiting for your answer.
Stop was pressed:octagonal_sign: Stopped. Mention me in this thread to carry on.
a gate was never approved:octagonal_sign: Nobody answered my question in time, so I stopped. Answer it and I will carry on.
the worker drained:octagonal_sign: I was shut down part way through. Mention me to carry on.
the token budget ran out:octagonal_sign: This conversation has used its allowance. Start a new thread to carry on.
the model-call cap was reached:octagonal_sign: I ran out of steps on this one. Mention me to carry on.
the run failed or crashed:x: and one line saying so

The budget and the step cap read as parked rather than as faults, since both lines tell the person where to carry on. A run that finished with nothing to say reads as answered, the run having finished.

No ending names a session, a tool call or a Go error. The worker log has all three, and a thread is read by everybody in the channel.

Where something went wrong on the way to the answer, such as a tool that ran out of time or a memory index the bot could not read, one message under the answer says so in a sentence. It names the kind of problem and nothing a tool returned.

Capacity

workers turns run at once. A mention that arrives with no slot free shows Queued... until one frees. Once max_waiting threads are already waiting, the next mention gets a short reply asking the person to come back in a few minutes, which is better than watching a queued message for three of them.

Three lines typed in ten seconds are one thought, so further mentions from the same person reach the bot as one follow-up turn, up to max_coalesced messages. A mention from somebody else gets a turn of its own behind that one, with its own status message and its own answer.

A turn that ended waiting on a question, or that somebody stopped, cannot take those extra lines. The thread gets them back as I did not get to: ..., so the person can see which of their messages went unanswered and send them again.

Shutdown and faults

A drain stops the channel taking mentions. A thread whose turn was still waiting is told the turn will not run, and a turn already going stops where the next mention can carry it on and says so on its status message. The connection closes last, so a turn still finishing keeps receiving stop presses and answers to its questions.

The socket mode client reconnects on its own, so a dropped connection is logged and waited out. A revoked or invalid token is a fault: fisk serve drains and exits non-zero, and a supervisor restarts the worker.

Warning

A worker that dies mid-turn leaves a status message that never changes again. Slack does not send the mention a second time, and nothing tidies the message up at startup. The conversation survives: the next mention in that thread carries on from what the journal holds.

One worker per bot token

Run one fisk serve per bot token. Slack allows an app up to ten socket connections and spreads envelopes across them, which this channel cannot use: the threads it is running and the questions it is holding are in process memory, so a button press delivered to a process that holds neither reaches nothing.

A press that lands on a worker with no record of the question still works, because the interaction is self-describing and the session derives from the thread. Everything else, from a Stop press to a mention folded into a running turn, needs the process that holds the turn.

Sessions

The session is a hash of the serving identity, the team, the channel and the thread, so two agents in one workspace keep their conversations apart and one agent keeps two threads apart.

A thread is a conversation, so the channel needs a session store it can read: fisk serve refuses to start the Slack channel without one. Threads outlive workers, so a deployment across machines wants a shared harness.sessions backend rather than the default file store.

Configuration

Every field under expose.agent.slack has a default, so an empty block is valid.

expose:
  agent:
    slack:
      # Turns this process runs at once. --workers does not reach it.
      workers: 5
      # Messages of surrounding conversation a turn reads.
      context_lines: 20
      # Turns off the status message, and the Stop button with it.
      no_progress: false
      # How long a question is held before the run defers.
      answer_grace: 30s
      # Admitted turns waiting for a worker before a mention is refused.
      max_waiting: 10
      # Messages folded into one follow-up turn.
      max_coalesced: 5
FieldDescription
workers (int)turns running at once, default 5
context_lines (int)surrounding messages a turn reads, default 20
no_progress (boolean)turns off the status message and the Stop button, default false
answer_grace (duration)how long a question is held before the run defers, default 30s
max_waiting (int)admitted turns waiting for a worker, default twice workers
max_coalesced (int)messages folded into one follow-up turn, default 5

workers defaults to 5 where the other channels default to 1. A thread is a person waiting, and with one worker the second person to ask anything watches a queued message until the first person’s run finishes.

--workers sizes the queued-jobs intake and does not reach this channel. context_lines covers both reads a turn makes: the conversation around a mention that opens a thread, and what was said in a thread since the bot last replied.

Safety

Channel membership is the whole of the access control. Anybody who can see a channel the bot is in can run the full agent loop against every tool the configuration allows, and can answer any question the agent asks there. Whoever can invite the app decides who reaches the tools.

Every run records its caller as the Slack username and user id, and nothing consults that record for a decision.

The rest of what applies to any served run is covered in Serving.