Slack CLI (co slack)
Experimental.
co slackwas tested against fakes, not yet a live workspace. Edit, delete and react are not implemented and say so. It is in 1.8.9 so people can try listen and reply on a workspace of their own; tell us what breaks. The read verbs (channels,history,thread,search) andco auth slackare newer still and equally untested on a live workspace.
Turn a Slack app you own into a directory of files. Every message your bot can
see becomes one line in a log and one file in a queue; anything that can read a
file can answer it. The same verbs as co discord, against a
different platform.
co slack check # both tokens, listener, connection, unread count
co slack listen # hold the Socket Mode connection; every message → ~/.co/inbox/slack/
co slack receive # block for the next message, take it, print one JSON line
echo "on it" | co slack reply C0123456789:1727500000.123456 # a reply, in that message's thread
co slack send C0123456789 "deployment finished"
co slack done C0123456789:1727500000.123456 # took it, decided not to answer
co slack consume -- claude -p # one command per message, stdout is the reply
co slack ls # what is waiting
co slack chats # which channels have spoken
co slack log -f # everything, as it arrives
co slack channels # the channels and DMs the bot is in, from Slack
co slack history ops -n 20 # what #ops said lately, oldest first
co slack thread C0123456789:1727500000.123456 # a message and its replies
co slack search "deploy failed" --in ops # needs SLACK_USER_TOKEN
The listener uses Slack's Socket Mode: it dials out to Slack over a WebSocket, so there is no public URL, no port opened, and no OpenOnion credential involved.
Setup
co auth slack
It prints a link that creates the app from a manifest already filled in: Socket Mode on, the events subscribed, the Messages tab on, and every scope the inbox and the read verbs need. Then a few clicks and three pastes:
- Open the link. Slack shows Create from a manifest with the manifest already in it (checked on a real workspace). Pick your workspace, click Next, then Create. Slack's two warnings, "This manifest was created by a 3rd party" and "Socket Mode … requires additional setup", are expected; step 3 is that setup. (If the form opens empty, choose From a manifest → JSON and paste the manifest it printed.)
- Install App → Install to Workspace → Allow. Copy the Bot User
OAuth Token (
xoxb-…) and, forco slack search, the User OAuth Token (xoxp-…). - Basic Information → App-Level Tokens → Generate Token and Scopes,
add
connections:write, and copy thexapp-…token. A manifest cannot make this one.
Each token is asked for at a hidden prompt, never as an argument (it would sit in your shell history). Without a terminal, pipe them in, in any order; the prefixes tell them apart:
printf '%s\n' "$BOT" "$APP" "$USER" | co auth slack
Every token is checked with Slack before anything is written: auth.test for
the bot and user tokens, apps.connections.open for the app-level token. One
that Slack rejects exits 1 and saves nothing. Scopes a token lacks (read from
Slack's x-oauth-scopes header) are listed with the step that adds them, and
the tokens that work are saved anyway. They go to the selected env file
(~/.co/keys.env unless co --env-file says otherwise):
SLACK_BOT_TOKEN=xoxb-... # reads channels, posts replies
SLACK_APP_TOKEN=xapp-... # opens the Socket Mode connection
SLACK_USER_TOKEN=xoxp-... # optional: co slack search runs as you
Press Enter at a prompt to keep the token already set, so running it again to add the user token later changes nothing else.
Then invite the bot to every channel it should hear or read:
/invite @YourBot in that channel. co slack check tests both tokens, and
names any read scope the bot token is missing.
The tokens are read only by the local process. They are never sent to
OpenOnion, never written into the inbox, and never included in an error.
co status and co keys show whether they were found and hide their values.
By hand
The manifest is the same app you would build by hand:
| where in the app settings | what |
|---|---|
| Socket Mode | on, with an app-level token that has connections:write |
| OAuth & Permissions → Bot Token Scopes | chat:write, app_mentions:read, im:history, channels:history, and for the read verbs channels:read, groups:read, im:read, groups:history, users:read |
| OAuth & Permissions → User Token Scopes | search:read (only for co slack search) |
| Event Subscriptions → bot events | message.im, app_mention, and optionally message.channels |
| App Home | Messages Tab on, with "Allow users to send … messages" ticked |
Any time you change scopes, Slack asks you to reinstall; do it, or the new scope is not in the token.
What lands
- A direct message to the bot: always, with
mentioned: true. - A channel message that @mentions the bot: always, with
mentioned: true. - A message in a thread the bot started or has replied in: when
message.channelsis subscribed, withmentioned: true, so a conversation can continue without an @mention on every turn. - Any other channel message the bot can see (with
message.channels): written withmentioned: false, like Discord, sologandchatsstill show the room;co aiandconsumeanswer only what is addressed to the bot. - Never: messages from bots (including this one), edits, deletions, joins, and other system subtypes.
With message.channels and app_mention both on, Slack sends an @mention
twice, once as each event. Both have the same id, so the inbox keeps one.
What a message looks like
{"id":"C0123456789:1727500000.123456","chat":"C0123456789","thread":"1727499000.000100",
"sender":"U0123456789","sender_name":"","text":"<@U0BOT00000> check the failed deployment",
"kind":"text","quoted":null,"mentioned":true,"at":"2024-09-28T05:06:40Z"}
idis<channel>:<ts>. Slack'stsis unique only within a channel, so the channel is part of the id.chatis the channel id:D…for a direct message,C…for a channel,G…for a private one.threadis the thread's roottswhen the message is in a thread, elsenull.sender_nameis filled only when Slack includes a profile in the event; looking names up would need another scope, so it does not.kindistext, orimage/audio/video/documentfor a file shared with no text; the text is then[image]and so on. The file is not downloaded (that would needfiles:read).
Sending
co slack reply <id> answers in the thread of the message: in the thread
it came from, or a new thread under it if it was a top-level message. That is
Slack's own advice (reply to the thread's root, never to a reply). co slack send <channel> "text" posts at the top of the channel; add --reply-to <id>
to post in that message's thread instead.
The text goes to Slack as typed: Slack reads its own mrkdwn (*bold*,
<https://x|link>, <@U123> to mention someone), not Markdown.
Slack advises keeping a message under 4,000 characters and truncates one over
40,000 rather than refusing it. co slack refuses anything over 40,000 before
anything is sent, because a silently cut answer is worse than an error; under
that, a long message shows with Slack's "Show more". One rate limit (HTTP 429)
is waited out, as long as Slack's Retry-After says (at most 30 seconds), and
retried; a second is reported.
Slack has no idempotency key for a post (Discord's nonce has no Slack equivalent). If a reply times out after Slack accepted it, retrying it posts it a second time; look at the thread first.
co slack edit, delete and react exist so the verbs match every other
provider, and say plainly that they are not wired up for Slack yet.
Reading the workspace
The inbox only has what arrived while a listener ran, and only what was
addressed to the bot or said where it listens. channels, history, thread
and search ask Slack directly, through its Web API, so an agent can answer
"what did #ops say about the deploy today?" without having been there.
co slack channels # id, #name or @person, kind, member count
co slack history ops -n 50 # latest 50, oldest first
co slack thread C0123456789:1727500000.123456
co slack search "deploy failed" --in ops --from alice -n 10
Each message prints as its local time, author, id and reply count, then its text indented beneath:
2024-09-28 15:10 Alice C0123456789:1727500200.000200 (2 replies)
@Bob deploy failed on staging
--json prints the same as one object per line, in the inbox's own field names:
{"id":"C0123456789:1727500200.000200","chat":"C0123456789","thread":null,"at":"2024-09-28T05:10:00Z",
"sender":"U0ALICE001","sender_name":"Alice","text":"@Bob deploy failed on staging","replies":2}
- Names, not ids. Authors and
<@U…>mentions are shown by display name, looked up once per person per run with the bot token (users.info, which is whyusers:readis among the bot scopes). Search resolves names the same way, so it needs the bot token as well as the user token. - Tokens saved as secrets count. Every token is read the way
co env getreads it: the process, the env file, then the storeco env set NAME value --secretwrites to. - The next step comes last. Each read verb ends with one
Next:line naming a real id from what it printed (a thread to open, or thesend --reply-toto answer in one). Under--jsonthat line goes to stderr, so stdout stays one JSON object per line. - Channels by name.
historyandthreadtake an id or a name:ops, or'#ops'in quotes, because an unquoted#starts a comment in bash. - Answering what you read. The ids are the inbox's own
<channel>:<ts>.co slack send <channel> "text" --reply-to <id>answers in that message's thread.co slack replyis for messages the inbox received, since it marks them answered there. - Threads.
threadgiven the id of a reply reads the whole thread from its root.historyshows a thread's first message with its reply count; the replies themselves are inthread. - Search runs as you. Slack's
search.messagesrefuses bot tokens, sosearchusesSLACK_USER_TOKEN(xoxp-, scopesearch:read) for that one call and sees what you can see, not just the bot's channels. Without that token it exits 1 and says how to add it.--inand--frombecome Slack's ownin:#opsandfrom:@alice; other modifiers (before:,has:link) can go in the text.
The co-slack skill walks an agent through the two common asks: "what did #ops say today" and "find the message about X and reply in its thread".
Everything here is read-only. When Slack refuses, the error says why and what to run next, and the command exits 1:
| Slack says | means | next |
|---|---|---|
missing_scope |
the token lacks the scope Slack names | add it under OAuth & Permissions, Reinstall to Workspace, co slack check |
not_in_channel |
the bot was never invited | /invite @YourBot in that channel |
channel_not_found |
no such channel for this token | co slack channels |
invalid_auth, token_revoked |
the token no longer works | co auth slack |
One rate limit (HTTP 429) is waited out as long as Slack's Retry-After says,
at most 30 seconds; a second is reported.
When the connection drops
Every event Slack sends is acknowledged by its envelope id after it is written to the inbox. If the write fails (a full disk), Slack is not told, so it sends the event again; a message the inbox already has is logged as a duplicate and not queued twice.
Slack refreshes a Socket Mode connection every few hours: it sends a
disconnect message, and the listener asks for a new URL and reconnects. A
dropped network does the same, with backoff up to 30 seconds. Nothing is
replayed across a full restart of the listener beyond what Slack itself
retries.
Some failures no reconnect can fix, and those end listen with exit 3 and the
thing to do: a token Slack rejects (invalid_auth, token_revoked), an
xoxb- token where the xapp- one belongs (not_allowed_token_type), and
Socket Mode being switched off in the app settings (link_disabled).
Upgrades reach the listener
A running listener keeps the code it started with, so after an upgrade it
restarts itself in place once no send is in flight, and check names both
versions while they differ. A listener started before version tracking cannot
do that; replace it with one command:
co slack listen --restart # stop the running listener, start a background one on the installed code
The details are the same for every provider; see WhatsApp: Upgrades reach the listener.
To have your own agent answer, list the channel in ~/.co/host.yaml under
listen: as slack and run co ai, exactly as for Feishu.
ConnectOnion