Slack as a source
The slack plugin brings Slack threads and DM topics in as units. What you are asked for there becomes items and tasks in your Inbox, agents read Slack through the daemon, and every post goes out as you only through a draft under its approval policy, or through a send that asks first. Setting it up is in Connect Slack.
What comes in
Section titled “What comes in”The daemon reads Slack through Socket Mode, plus an ingestion job every minute: a mention search every 5 minutes, and a catch-up from each conversation’s last read message after a start, a reconnect or a gap. Its first start reads back 7 days (plugins.slack.ingest.since sets another date).
A message is admitted when it:
- mentions you;
- contains one of
plugins.slack.ingest.terms(whole words, any case); - replies to a message of yours, or sits in a thread you wrote in;
- is in a DM or group DM;
- belongs to a thread you follow (
wloop slack follow <message>, a message ref or permalink).
Anything else is dropped and never stored. Messages of bots and of Slackbot are dropped unless plugins.slack.ingest.allow_apps lists their bot or app id; deny_apps drops one always. A post an agent app makes on behalf of a person counts as that person’s.
doctor and the cockpit warn when the socket has been down for 5 minutes, when messages arrive only through the catch-up (the app’s event subscription may be off), and when the mention search fails.
Extraction
Section titled “Extraction”The extract:slack job turns the new messages of each unit into item changes, with one model call per unit on the fast tier. It waits until a unit has been quiet for 90 seconds, and never longer than 10 minutes after its oldest change, so a burst of messages costs one call. A message that lands asks the daemon to run the job when its quiet time ends, a run says when the next unit’s time comes (never before a spent daily cap frees, the breaker’s trial or a failed unit’s retry), and a brief a first turn wants runs it at once; a run every 5 minutes catches a wake that was lost, and a failed run is retried after a minute, doubling up to those 5 minutes, whatever it said of its next. A unit whose read fails before its call waits its quiet time, doubling, like one whose call failed, without counting toward giving up on it. DM conversations are split into topic segments, each its own unit, in the order of their first message (a continued topic the model leaves open beside a newer one ends at its last message before that one), with up to 10 earlier messages shown as read-only context: an item or a link that cites one as its origin or evidence is dropped, and message text that looks like the context or segment markers is defused. When the messages link a Slack message of another unit, the call also reads the open items of the first five such units (at most 20 items: id, summary, requester, assignees) as a read-only cited block, which the plugin’s profile words in prompts/slack-cited.md ({{ITEMS}} stands for the items) and which shows only when there are cited items, with any text of a message or an item that repeats its marker lines defused; the model may resolve or reassign one of them on a message of this conversation by you, the item’s requester or an assignee, which changes that item on its own unit, and never restates it as a new item. The model names each person by Slack id (a user or a user group) or, for a team or role without one, by name, and reads the ledger’s people as the conversation renders them (<@U…|name>, <!subteam^S…|name>); an answer naming a person any other way is refused like one that does not fit the schema (the unit backs off and, after repeated failures, is given up until its next change), so a malformed key never reaches the ledger. The first pass after an upgrade from a version up to 0.2.0 rewrites, once, the people those versions keyed by their rendering (group:ana (U…)), keeping each item’s assignment time and leaving your confirmed items as they are. Your confirmed items are never overwritten. Each item carries a summary, written for your task list, which names everyone but you and speaks of you as “you” or leaves you implicit, and an ask: the item as you would write it in the conversation, you in the first person, addressed in the second person to whom it waits on when that is someone else, which every draft a workflow posts lists. A confirmed item gets its ask once, when it has none. An item extracted before asks existed keeps its old summary, written before the “you” rule, and has no ask until the extraction reads its unit again, which takes a new message there; its drafts list that summary meanwhile.
The model extracts every item of a conversation, for the brief, but only an item that waits on you, or that you requested or are assigned while it waits on someone else, becomes a task (see From items to tasks), unless the unit is a thread you follow or a 1:1 DM with a person: an ask of someone else, another person’s commitment to a third, or an entry under a teammate in a status list stays in the brief. A request to review, approve or merge a pull request is linked to it: it resolves when an assignee reviews it or it merges after the ask, as the link is stored when that already happened.
Spend is capped: $0.10 per call, $3.00 and 500 calls per local day; a day’s cap frees at local midnight, and the job’s skip line says when. After 3 failed calls in a row the job pauses for 15 minutes before a trial call. plugins.slack.extraction.enabled: false turns it off. The configuration reference lists every knob, and the extraction eval measures the extraction against your own conversations.
The agent tools
Section titled “The agent tools”While Slack is configured and the plugin is on, the daemon’s MCP server serves these tools to agent sessions:
| Tool | Does | Slack calls and scopes |
|---|---|---|
slack_read | reads the messages of a unit (slack:<key>), or the thread of a message ref or permalink, 50 by default, up to 200; each message prints with its ref | conversations.replies, conversations.info (channels:history, groups:history, channels:read, groups:read) |
slack_search | searches the messages you can read, newest first, with a query and optional channel, from, after, before and limit (20 by default, up to 100); one line per message: permalink (the ref when Slack gives none), author, time in your time zone and first line; a direct message shows only when tracked | search.messages (search:read); conversations.info for a channel id and users.lookupByEmail for an email (users:read.email) |
slack_draft | proposes a draft (reply, nudge, close, ask_owner, react) on the turn’s Slack unit, or in the thread of a message, by its ref or permalink, any thread; from a turn on a unit whose plugin posts nothing, such as a task, also to a channel or a person; it posts nothing, and the draft waits in the Outbox | once approved, chat.postMessage (chat:write) or reactions.add (reactions:write) |
slack_react | adds a reaction to a message, by its ref or permalink, or with remove takes yours off, now, without a prompt (install allows it) | reactions.add, reactions.remove (reactions:write); conversations.info |
slack_edit | replaces the text of one of your own messages, by its ref or permalink, now, without a prompt (install allows it); it refuses a message someone else posted, and the record keeps the text it replaced | conversations.history or conversations.replies to read the message and its author, then chat.update (chat:write) |
slack_delete | deletes one of your own messages, by its ref or permalink, in a thread or a Slack Connect channel too, now, without a prompt (install allows it), and tells the agent to ask you first when it is unclear whether you want the message removed; it refuses a message someone else posted, and the record keeps the text the message held, so a deletion can be undone by hand | conversations.history or conversations.replies to read the message and its author, chat.getPermalink, then chat.delete (chat:write) |
slack_send_message | posts as you, in the thread of a message by its ref or permalink, or to a conversation or person, now, without a prompt (install allows it), and tells the agent to ask you first when it is unclear whether you want the message posted in your name; a post in a tracked unit is recorded as a sent draft; it refuses @channel, @here, @everyone and group mentions | chat.postMessage (chat:write) |
The message ref
Section titled “The message ref”A message’s ref is <conversation>/<ts>, such as C0123ABCD/1700000000.000100: its conversation id and its Slack timestamp. slack_read and a Slack turn print every message as [m<N> <ref>] <author>, and every tool that takes a message (slack_react, slack_edit, slack_delete, slack_draft, slack_send_message and slack_read itself) takes that ref or the message’s permalink, through one parser, so an agent can act on any message it has read; the ledger anchors an item on the same ref, the Outbox records the message a post made or reacted to by it, and every command that takes a unit takes it for the message’s thread; outbox new <message> --react <emoji> reacts to that message itself. m<N> only numbers the messages of one read, for reply to m<K>. A ref does not say the thread its message is in: a reply to it goes into that thread, which wloop reads from the store when it holds the message, and from Slack (conversations.replies) when it does not, when the stored message is deleted, when search stored it without saying its thread, or when the store fails. A permalink’s thread_ts, when it has one, says the thread itself. Each message prints its ref and not its permalink, which would need the team’s URL and cost about 80 characters a message; the first message’s permalink heads the read.
Where a draft posts is decided when it is proposed and stored as its target, so the Outbox shows it before you approve and the publisher never guesses; the conversation and the thread it names are read from the plugin’s store, and Slack is asked only about one the store does not hold. A draft with no target on a thread posts in that thread. A direct message segment has no thread of its own: its draft answers the newest message its items name, as a reply in that message’s thread, which a top-level message starts. A reaction goes on the newest message one of its items started at, the request you answer, never on a later message of the trail, which may be your own. A message that replies in a thread carries a broadcast, which the Outbox card shows as Also send to the conversation: on, Slack also posts the reply to the conversation (reply_broadcast). Its publisher sets it when the draft is proposed, unless the proposer asked for one (slack_draft’s broadcast, or the task page composer’s checkbox through the reply verb’s broadcast), from the Slack profile layer’s drafts.toml: on in a direct message or a group direct message, where a reply in an older thread would not show in the conversation, off in a channel, and off for a reply you write yourself, since you chose the thread. A user profile’s plugins/slack/drafts.toml replaces that file, and a key it leaves out is off. A draft held or failed from before broadcasts existed takes its broadcast when the daemon starts, its gate left as it was. You turn it on the card, or with the edit verb’s broadcast argument, until the draft is sent; a change waits for your approval again, as an edit of the text does. A draft that posts at the top level of a conversation, or a reaction, has none. A message you write on a segment from the cockpit or outbox new with no items goes to the conversation, as typing in the direct message does; a reaction you write there with no items has no message to land on and is refused, since neither names a message. A draft an agent or a workflow proposes on a segment with neither a target nor items that name a message of the conversation is refused when it is proposed, with the reason. The workflows’ slack-closure and slack-ask-owner drafts take this path. A draft stored without a target before placement existed is placed when you approve it, retry it, its policy releases it, or the sender takes it already approved; one a previous version left sending is looked for at the top level of the conversation, where that version posted it.
A draft slack_draft proposes on a thread that is not the turn’s unit, such as a team’s thread from a pull request’s turn, names that turn and its unit, answers no item, declares no effect and waits for your approval. A draft to a channel or a person from a turn on a task stays on the task, and the Outbox gives it to the plugin whose publisher takes its target, the Slack one, which posts it at the top level of the channel or in the direct message once you approve it. A target that more than one plugin takes is refused, and a turn on a unit whose plugin posts keeps its drafts on that plugin whatever their target. Every post slack_send_message, slack_react, slack_edit and slack_delete make is recorded in the Outbox as a sent draft, on the thread or segment it landed in, tracked or not, with the turn that made it. A post in a direct message no segment covers yet is recorded on its message, and moves onto the segment that covers it once the extraction segments the conversation. In the text of slack_draft, slack_edit and slack_send_message, an @handle, an @<user id> or an email that names one person of the workspace becomes a mention of them, as does a typed <@<user id>> or <@<user id>|label>, whose label is dropped; one that names nobody, or whose lookup fails, stays text, one several people hold is refused, and code spans, code blocks, links and URLs are left as written. An edit also clears the message’s blocks, so the new text is what Slack shows. A draft posts every other mention, channel or special token it spells, such as a channel’s <#<channel id>> or a <!date...>, as text, and refuses <!here>, <!channel>, <!everyone> and a group’s <!subteam^...>. A draft keeps the mention as @{slack:<user id>}, so the Outbox shows whom it pings; outbox show of a recorded edit shows the text it replaced, and a recorded deletion carries the text the message held as its text, which every Outbox view shows. Resolving a handle reads users.list (users:read), an email users.lookupByEmail (users:read.email). Every scope above is in the app manifest and the login’s scopes; none is new.
The Slack plugin tells a thread’s language as slack.language, an ISO 639-1 code read from the text of its newest 50 messages people wrote, yours included, with mentions, links, emoji codes and code left out; a thread with fewer than 40 letters left, written in two languages, or whose language the detector is not sure of reads as empty, and its drafts are written in your language. The manifest names it as its language_fact, so the workflows’ drafts on a thread are written in its language when a profile layer translates their text (What a draft says about itself).
The plugin tells who can read a conversation as slack.members, the person keys of its members, you included, and names it as its members_fact, so a workflow’s draft.propose addresses only the members (to = "members") or only the others (to = "non_members"), and a draft counts draft.people_outside. A direct message’s members are you and its peer, read from the store with no call. A group direct message’s or a channel’s are read with conversations.members (channels:read, groups:read, mpim:read, paced at tier 4), by the ingest job, for the conversations whose units hold an open item that waits on someone, the least recently read first, once a day each and at most 10 conversations a job, 1000 members a page and at most 10 pages; the job runs every minute, so a sweep reads nothing once the day’s reads are done, and a conversation’s first read costs one call per page. A read that fails, which the plugin logs, waits a day like one that succeeded. Until a conversation is read, or after its read failed, slack.members is absent and the nudge goes as before: everyone it waits on is mentioned in the conversation, nobody is written to elsewhere, and nothing is withdrawn as outside. A user group or a team named by text is never judged by membership either: it is mentioned in the conversation as before. The bundled slack-stalled-nudge mentions the members on the conversation, and slack-stalled-nudge-dm writes each person known not to be in it in your direct conversation with them, quoting what they owe; that draft always waits for you, whatever your approval rules say (gates.drafts_wait), its card says which conversation it quotes, it is not proposed to someone plugins.slack.post_allowlist leaves out, and it is withdrawn as joined once the person joins the conversation.
A post a turn on a task makes, recorded at once or sent once approved, is attached to that task when the turn’s intent sets drafts.attach_posts; reactions and edits are not posts. The Slack plugin says a unit has ended (slack.ended) once a closure was posted or marked (unit.closed), or once its newest stored message is older than plugins.slack.ended_after (7 days); a task you captured closes once every unit attached to it has ended (see Units attached to a task).
The plugin also reports two link signals of a unit with open items, which a task reads as task.link_signal.<name>: asked_reacted, every open item has a check mark (white_check_mark, heavy_check_mark or ballot_box_with_check, any skin tone) from whoever asked it or whom it waits on, other than you, on a message posted after its ask, such as your answer; and replied_last, you posted the unit’s newest person’s message, after every open item’s ask, at least 3 days ago, and every open item waits on you or got a message from someone else after its ask. The bundled workflows slack-done-reacted, slack-done-replied and slack-done-quiet read them, and task.link_ended, to offer closing the thread’s task (see Closing a task its source shows done).
From a terminal, wloop slack send <target> <text> shows the message and asks before it posts, unless --yes. A target is a message ref or permalink (a reply in its thread), #channel, a channel id, @handle, an email or a user id. A reply in a thread goes through the Outbox as a draft of yours, approved when you say yes, with --broadcast asking it to also post to the conversation; the other targets and --thread=false post directly. wloop outbox new <message> <text> writes the reply as a draft that waits for your approval instead. plugins.slack.post_allowlist, when not empty, is the only set of channels or users any post may reach.
Tokens
Section titled “Tokens”Tokens live only in the macOS login Keychain, under the service wloop-slack (plugins.slack.keychain_service). wloop never puts them in a command line, a log, the store or a tool result, and never hands one to an agent. Any process running as you can still read your login Keychain with security.
The user token is refreshed before it expires. When Slack refuses the refresh you are logged out: every Slack tool call fails naming wloop slack login and posts nothing. doctor and the cockpit warn when the login is 25 days old, before Slack’s 30-day limit.