@stratusagent/channel-slack
v0.6.0
Published
Slack adapter for Stratus agents: one Slack app per agent (real avatar and presence), Socket Mode, thread-keyed resumable sessions, and streaming replies via message edits
Readme
@stratusagent/channel-slack
Slack adapter for Stratus agents. One Slack app per agent — Slack has no way to give a single bot several identities with real avatars, presence, and DMs, so each agent gets its own app, and the adapter runs one Socket Mode connection per agent (no public ingress needed; Mac Minis behind NAT are fine).
- Resumable conversations: session keys are
slack:<agent>:<team>:<channel>:<thread_ts ?? ts>(DMs: the DM channel id) — a thread is a conversation, it survives daemon restarts, and two agents sharing a thread keep fully separate sessions. - Streaming replies: post a placeholder, edit as deltas arrive (throttled for
chat.updatelimits), show⚙ tool…status lines, finalize with the full reply — split across messages when it outgrows one. - Mention-only in channels, free-form in DMs. Inbound mentions of other users are humanized to
@Display Namefor the model; redeliveries are deduped so a slow turn never runs twice. - Approval buttons for the gateway's
remotepermission mode: a gated tool call parks the turn and asks in its thread with Allow once / Always allow / Deny. See Approving tool calls — clicks are authorized by who clicked, never by who can see the message. - Tokens are gateway infrastructure secrets, stored in the
channelsnamespace of~/.stratus/credentials.json— never in an agent's credential allowlist:
{
"channels": {
"slack": {
"ava": { "appToken": "xapp-…", "botToken": "xoxb-…" }
}
}
}Installing
This package is an optional peer of the CLI — stratus ships without any
transport, so installs that never use Slack do not carry the Slack SDKs
(~9 MB). Add it alongside the CLI to enable the channel:
npm install -g @stratusagent/channel-slackstratus serve picks it up automatically for every roster agent with stored
Slack tokens. Without it, tokens are reported at startup with an install
hint and the daemon serves every other channel as usual.
Setting up an agent's Slack app (~2 minutes)
The easy way: run stratus setup → Channels. It prints the manifest with
the agent's name already filled in, takes both tokens without echoing them,
verifies each against Slack, and stores them under the right agent id — no
editing credentials.json by hand.
The manual equivalent, if you prefer:
- https://api.slack.com/apps → Create New App → From a manifest → paste
manifest/stratus-agent.manifest.jsonwithNAMEreplaced by the agent's name. - Basic Information → App-Level Tokens → generate a token with
connections:write(that's theappToken,xapp-…). - Install App to the workspace → copy the Bot User OAuth Token (that's the
botToken,xoxb-…). - Upload the agent's avatar under Display Information.
- Add both tokens under
channels.slack.<agentId>in~/.stratus/credentials.jsonand restartstratus serve— the log will showslack: <agentId> connected.
Approving tool calls
When stratus serve runs with approvals.mode: "remote", a gated tool call
parks the turn and the adapter asks here. The question goes to the thread the
turn is happening in; a turn with no Slack conversation of its own (scheduled
work, a delegate) asks in the configured slackChannel.
Who may answer is configured per agent, in ~/.stratus/config.json:
{
"approvals": {
"mode": "remote",
"slackChannel": "C07OPS",
"agents": {
"ava": { "slackApprovers": ["U01DYLAN", "U01OPS"] }
}
}
}That block is only read from a config you chose — --config,
STRATUS_CONFIG, or the global ~/.stratus/config.json. A project-local
stratus.config.json cannot appoint approvers, since it can be checked into
any repository.
Clicks are authorized by actor, not by delivery. Everyone in a thread can
see the request; only the ids listed for that agent can decide it. Anyone
else's click is refused with a notice only they see, and the request stays
open for someone who may actually answer — which matters most for Always
allow, since that widens what the agent may do for the rest of the session.
An agent with no approvers listed denies every request on arrival rather than
leaving it hanging — set "slackApprovers": [] on an agent to exclude it
from a shared default list. The same goes for an agent whose Slack app is
configured here but failed to connect. An agent this adapter was never given
is left alone: a request is a broadcast, and refusing one another channel was
about to answer is not the adapter's call — stratus serve reports agents no
channel can ask for, at startup. Those automatic denials are recorded as
undeliverable rather than decided, so the log never shows a refusal
nobody made as one somebody did.
The message shows the tool's arguments as well as its name — approving
shell.run without seeing what it would run is not approval. Arguments are
escaped, so a model-written argument cannot mention or broadcast to the
workspace through the prompt itself, and long ones are truncated with a
notice saying so.
Requests are also denied, visibly, when they expire, when the turn is cancelled, and when the daemon shuts down. Every ending the daemon is alive for retracts the buttons, so a message does not keep offering a decision that has nowhere to land.
A crash is the ending it cannot be alive for, and the record of what was posted lives only in memory — so a new process cannot find its predecessor's prompts to retract them. Those correct themselves on the next click: it answers that the request is no longer pending and rewrites the message, since the click is the one thing that carries the message's location back. A prompt nobody clicks stays as it was until someone does.
Interactivity must be enabled on the app or Slack delivers no clicks at
all. The shipped manifest turns it on; an app created before this shipped
needs settings.interactivity.is_enabled set to true once, under App
Manifest. No request URL is needed — the clicks arrive over the same Socket
Mode connection.
