A single module, slack, plus its FFI sidecar src/slack.ts: messages in and out (watch_messages,
send_message, try_send), and one interaction primitive (ask) covering every human-in-the-loop shape.
Socket Mode means no public URL and no request-signature verification — the sidecar opens an outbound
WebSocket with the app-level token and Slack pushes events over it, while the bot token drives the Web API.
Files are first-class both ways. It is the Slack half of a twin contract with the discord package: same
types, same fields, same argument names, so a bot ports between them by swapping the import.
slack.provider(bot_source, app_source)— servesslack.credential, the workspace's two tokens, for the extent of the continuation. It connects nothing.slack.watch_messages(channel, deliver_to)— serve a channel forever, delivering each incomingslack.message(id, channel, author, text, files, thread)to your agent, whose argument is namedvalue. Bot posts (this bot's own included) are not delivered, so replying cannot loop. The watch supervises itself — a runtime restart's interruption reopens the connection on a capped backoff, whileauth_errorstill stops it loudly;slack_watchis the mortal single-connection form for a caller composing its own policy.slack.list_messages(channel, after ?= "", limit ?= 50) -> array[message]— the channel's own history after a messagets, in posted order, as the samemessagethe watch delivers. Needschannels:history.slack.send_message(channel, text, files ?= [], thread_ts ?= null) -> string— post to a channel, returning the posted message'sts; a message'stsasthread_tsreplies in its thread.slack.try_send(channel, text, files ?= [], thread_ts ?= null) -> delivered | dropped(reason)— the resilient wrapper: blank text with no files posts nothing, a transientapi_errordrops just this post,auth_errorre-raises.slack.ask(channel, prompt, controls) -> answer— post a prompt with controls and block until a member of the channel answers. The channel's membership is the trust boundary.slack.limits() -> caps,slack.check_controls(controls) -> valid | invalid(reason)— Slack's numbers as data, and "is this question askable?" as a value. Both pure; check where the controls are built.
Socket Mode delivers to open sockets. A running watch receives every event at least once — Slack re-sends
one whose acknowledgement was lost — and keeps no dedup memory, so a bot that must not act twice dedupes on
its own. Messages posted while no watcher is running sit outside that stream, in the channel's own history:
list_messages reads them back, so a bot keeps the id of the last message it handled somewhere durable
and reads forward from it.
Every call carries the tokens it acts with, and a connection belongs to the call that needs events, so it ends when that call does. Re-forking a watcher after a runtime restart opens a fresh connection. The interrupted call itself ends once, under the at-most-once rule, and whoever wanted that answer asks again.
slack.slack_error is auth_error(message) or api_error(message), raised as a prelude.throw, never a
panic.
auth_error— the token is invalid, revoked, or missing a scope; an operator resolves it. A rotated token needs no restart: every call resolves the credential afresh.api_error— a rate limit, a transient fault, a channel the bot is not in, a payload over one of Slack's caps. Catch it to drop just that reply and keep serving.env.missing_secret/oauth.server_errorsurface from the provider's install site, on the first call that needs a token. A token that is present but bad surfaces from the call that used it.
Slack enforces every cap itself and this package clamps nothing, so an over-cap or blank string comes back
as api_error, and a form's own caps show when its dialog opens rather than when the question posts.
ask takes a list of controls and returns one answer, so the four human-in-the-loop shapes are four
control lists rather than four agents:
| shape | controls | answer |
|---|---|---|
| approval | two buttons |
clicked(id, by) |
| open question | a one-field form |
submitted(id, values, by) |
| draft review | a form prefilled with the draft, beside a reject button |
submitted / clicked |
| multiple choice | a select |
chose(id, option, by) |
Every control is live at once and the first answer settles the ask; the controls are then stripped from the
message. Branch on the control's own id — case slack.clicked(id => "approve", by => _) — never on
display text. Each control's id must be distinct within one ask: it is the correlation key Slack carries
back.
- A
formis two-stage on Slack, because a dialog cannot be opened out of the blue: the form posts as an ordinary button, and pressing it mints the three-second interaction token its dialog opens with. So opening a dialog and closing it again is not an answer, and a dialog that cannot be opened isapi_error. - Every box of a
formis optional and comes back as""when blank, sosubmitted.valuesis total over the declared fields. The socket acknowledges each interaction on arrival, which is what closes the dialog, so a submission carries no per-field errors: validate in the program and ask again. byon an answer, andauthoron a message, is a raw Slack user id (U…), drawn from a workspace's small and enumerable space. Pass one through the prelude'scrypto.pseudonym(key, value), resolving the key at the call site, before letting it leave the program.
scripts/check-twin.mjs (pnpm test) compares both modules' published names, data fields, agent arguments
and callback arguments. Anything that differs must be declared in the script with a reason, and those
declarations are the list — run it to print them, and after any change to either package's surface. Two
behaviours the identical types hide: Slack enforces its own caps and this package clamps nothing, where the
twin clamps a caption through @discordjs/builders; and a watch here is at-least-once while running, where
Discord's gateway acks only heartbeats and so can both drop and duplicate.
- Create an app at api.slack.com/apps ("From scratch").
- Socket Mode: Settings → Socket Mode → enable. Generate the app-level token with the
connections:writescope — this is thexapp-…token (SLACK_APP_TOKEN). - Scopes: Features → OAuth & Permissions → Bot Token Scopes:
chat:write(post),files:write(upload attachments),files:read(download incoming attachments), andchannels:historyforlist_messages(groups:historyfor a private channel). - Events: Features → Event Subscriptions → enable, then under "Subscribe to bot events" add the message
events for the conversations you watch:
message.channels,message.groups,message.im. Socket Mode needs no Request URL. - Interactivity (only for
slack.ask): Features → Interactivity & Shortcuts → toggle on. No Request URL here either. - Install to the workspace: OAuth & Permissions → Install. The Bot User OAuth Token is the
xoxb-…token (SLACK_BOT_TOKEN). - Invite the bot to the channel (
/invite @your-bot) and copy the channel id (theC…value).
SLACK_BOT_TOKEN— the bot token (xoxb-…), for every Web API call and attachment download.SLACK_APP_TOKEN— the app-level token (xapp-…), only to open the Socket Mode connection:watch_messagesandaskneed one, and a bot that only posts never opens one at all.
Store both with katari env set SLACK_BOT_TOKEN --secret and katari env set SLACK_APP_TOKEN --secret.
Each is a string of private, which keeps a token out of a log, a store or an outbound message.
src/slack.ts imports @slack/socket-mode, @slack/web-api and @katari-lang/port, declared in
package.json; run pnpm install here so katari apply can bundle the sidecar.
import slack
agent echo(value: slack.message) -> null {
match (value) {
case slack.message(id => _, channel => channel, author => author, text => text, files => files, thread => thread) -> {
let _outcome = slack.try_send(
channel = channel,
text = f"<@${author}> said: ${text}",
files = files,
thread_ts = thread,
)
null
}
}
}
agent main() -> never {
use slack.provider(
bot_source = credentials.env(key = "SLACK_BOT_TOKEN"),
app_source = credentials.env(key = "SLACK_APP_TOKEN"),
)
slack.watch_messages(channel = "C0123456789", deliver_to = echo)
}
Hand slack.send_message (or a doc-on-let rename of it) to an AI loop's tool list to let the model post
into the channel on its own.