channels
Operate the communication channels — the peers on the-loop's event bus, starting with the Slack bot (issue-245, issue-309).
the-loop channels status # ledger, subscribe/publish grants, catalog with ticks — no secrets
the-loop channels threads # which Slack thread carries which work item's conversation
the-loop channels records REF # the context and decision records a channel wrote on a ticket
the-loop channels poll # one read cycle: bound threads, and top-level messages when granted
the-loop channels listen # Socket Mode, foreground — replies, button presses (Approve, Execute, Start), kickoffs, /the-loop
the-loop channels manifest # the Slack app manifest to import (scopes, events, the command)
the-loop channels manifest --name NAME --write # renamed, kept beside the CLI config, with the one-click linkWhat it does
statusprints the ledger, the resolved Slack configuration — with token presence only (set/unsetplus the env var's name), never a value — how many people ofrouting.authorizedUserscan speak on it, whether the Approve / Request changes and the Execute / Start buttons can be received — and, while either cannot, the numbered steps that still apply: the app-level token (where to mint it, its scope, its variable's name and presence),read.mode: socket, the missing grants, the restart (issue-337) — the kickoff target, how many conversations (thread bindings, cursors) the channel state holds, and the catalog: every subscribable event with a tick wheresubscribenames it, and every publishable event with a tick wherepublishgrants it — so neither list is ever configured by guessing names. Since issue-325 it also prints the reactions line: which emoji acknowledge an accepted message, oroff; and since issue-338 thelongMessagesline besidemaxChars— what happens to text longer than the cap (digest: the ask first, choices numbered, code and traces as pointers, cut at a sentence, the link for the rest;truncate: the firstmaxCharscharacters). Since issue-362 theread:line also names socket mode's reconcile cadence (read.catchUpSeconds), and achannel kind:line says what kind of conversation the configured id is — read from the id's own prefix, sostatusstill calls nothing — with the bot scope and bot event that kind needs; aD…gets a[!]finding, because a Slack app imported from a pre-issue-362 manifest cannot receive a direct message's events at all.--probereplaces the guess with the measured answer: oneconversations.infoon the configured channel and oneauth.testfor the scopes the app was actually granted, printed by name. A probe that cannot run (no bot token, no channel, an API error) says why, andstatusstill exits 0 — a diagnostic never fails the command, and no token is ever printed. See which events your channel needs.threadslists the conversations: one line per work item with the Slack channel id, the thread ts, when it was opened, how (event— the-loop opened a root for the first event it delivered;kickoff— a member's top-level message became the work item and that thread is its conversation;legacy— a binding from before issue-312, derived from the thread map) and the thread's permalink when Slack returned one.--work-item <ref>shows one (exit 1 when it has none);--jsonprints the records. Since issue-389 alistencolumn says what the conversation hears:allfor a direct message with the bot or a room declared--listen all, elsementions. It reads the state file only — no Slack call, no token — and prints ids, never a message's text.recordslists the records a channel wrote on a work item's ticket (issue-389): every marked, envelopedcontext.added(an@the-loop record-contextsnapshot) anddecision.recorded(an@the-loop record-decision) comment, each with the ledger's URL, the envelope's actor and time, the visible line naming the act, the quoted text and — for a decision — the rationale and the thread it was made in. The default is markdown with the text quoted back, pasteable intocontext.mdor a decision record;--format jsonprints the rows;--type(repeatable) keeps one kind. A session runthe-loop channels records <ref>at the start of every phase to find what it has not folded (see collaboration). One read of the ticket's comments — one read answers for an issue and a pull request alike — no Slack call, no token printed; exit 1 when the ref is not one or the ledger cannot be read, with the reason on stderr.pollruns one synchronous read cycle: every bound Slack thread is checked for new replies and — with thework-item.creategrant and akickoff.repo— the channel for new top-level messages. Each message is classified into one event type, checked against the channel's grants, recorded on the ledger and, for a plain reply, delivered into the waiting session. This is the cron-friendly form of what the daemons do continuously whenread.modeispoll— and, since issue-334, the reconciliation you may run beside a Socket Mode listener: the cursors are shared, so a cycle insocketmode reads only what the listener missed. Exit 1 when the cycle was skipped (channel disabled,read.mode: off, missing token), with the reason printed.listenis the foreground form of the Socket Mode listener thatthe-loop starthosts inside the service wheneverchannels.slack.read.modeissocket(issue-334) — forservice.hostIngresses: false, or for watching the connection in a terminal. It takes the same single-instance lock (<root>/slack-listener.pid) and refuses to run beside a hosted one. It connects over Socket Mode (the official SDK's built-in client, an outbound connection — nothing to expose) and processes messages push-fashion until interrupted: thread replies, top-level messages, Block Kit button presses, which enter the pipeline as that member's reply carrying the button's text, and — since issue-334 — the/the-loopslash command, acknowledged first and answered ephemerally through itsresponse_url. On connecting it runs one catch-up read (thepollcycle) over every bound thread and the kickoff cursor, so replies posted while no listener was connected are processed once (channel.caught_up); a retry Slack later delivers of a message already handled is dropped asduplicate. Needs both tokens: the bot token to act, the app-level token (xapp-…,connections:write) to connect.manifestprints the packaged Slack app manifest — bot user, scopes (chat:write,channels:history,groups:history,reactions:write,commands), event subscriptions, interactivity, Socket Mode and the/the-loopcommand — for Create New App → From a manifest. No config, no token, no network. The Slack integration guide walks the setup. Since issue-464:--name NAMErenames the app for its operator:display_information.nameis NAME (35 characters at most) and the bot'sdisplay_nameis its handle (lowercase, each run of characters outsidea-z0-9._-turned into-). Every other key is the packaged manifest's. A name Slack would refuse exits 2.--format jsonprints JSON.--linkprints Slack's one-click create URL,https://api.slack.com/apps?new_app=1&manifest_json=….--write [PATH]keeps the manifest as JSON, by default asslack-app-manifest.jsonbeside the resolved CLI config. Without--nameit reuses the kept file's name, so a re-run after an upgrade rebuilds the file from the new release. It printsunchanged(and does not rewrite the file), or the bot scopes and events added and removed, and whether the existing app needs its App Manifest replaced and a reinstall. Then it prints the link. A file it cannot read as a manifest is left untouched unless--nameis given (exit 2).
statusalso prints, since issue-389, amentions:line — the mention is the address in every channel, delivered asapp_mentionover Socket Mode and needing theapp_mentions:readbot scope, or nothing addressed can arrive whenread.modeis notsocket— ashortcuts:line naming the two message shortcuts and their callback ids, and arooms:line counting the declared rooms and how many of them hear every message (--listen all).--probemeasures the mention scope beside the kind's and reports its absence as a[!]finding naming the consequence.statusalso prints asplit check:line in socket mode (issue-413): the cadence the listener measures its own share of the inbound traffic at, and what the last check read — or[!]and the remedy when the last eight checks were not all clean, which means a second Socket Mode consumer is holding this app-level token and Slack is splitting the app's envelopes between the connections. It reads the state file the listener writes and calls nothing, so the un-probed form still makes no API call.statusalso prints acommands:line: which slash-command families this channel may run (work-item—control.command;instance—instance.command;standing—standing.command), or that none can arrive becauseread.modeis notsocket.statusalso prints ajira:block (issue-475): whetherintegrations.jirais configured, its site and deployment, each project with the repository it maps to (ormirror-only), whether the credential variables are set (by name, never the value), the webhook route and its secret's presence, where the ledger records a Jira work item's events, and thechannels.jiramirror. Nothing is sent to Jira.
However a message arrives — a poll cycle here, the daemons' background reader, or the listener — it goes through the same pipeline: bindings decide relevance, the bot's own messages are dropped, the member id is checked against the slack ids of routing.authorizedUsers fail-closed, the message is classified (control keyword → open gate → reply) and matched against publish, then recorded on the ledger first. A work-item.reply is then delivered into the waiting session through the same fail-closed path POST /api/v1/sessions/reply uses — never spawning or resuming anything; a gate.feedback or control.command stops at its record, because the ledger's own ingress is what acts on it; a work-item.create is the issue itself. A slash command takes a different path (channels/commands.py): the same allow-list first, then a fixed vocabulary, then the family's grant; a work-item verb publishes control.command and stops at the ledger record exactly as a typed keyword does, while status / restart / upgrade and the standing verbs call the core facade the API exposes. The message that was accepted is acknowledged on itself (reactions): 👀 the moment it passes the grant, ✅ when the pipeline's action landed, ⚠️ when it did not — and nothing on a message that was dropped.
Flags
| Flag | Default | Meaning |
|---|---|---|
| (action) | required | One of status, threads, records, poll, listen, manifest. |
--name NAME | packaged name | manifest only: the app's name in Slack; the bot's handle is derived from it. Under --write, defaults to the kept file's name. |
--format yaml|json | yaml | manifest only: print the manifest as YAML or JSON. |
--link | off | manifest only: print Slack's one-click create-app URL instead of the manifest. |
--write [PATH] | off | manifest only: keep the manifest as JSON (default slack-app-manifest.json beside the CLI config), report what changed, print the link. |
--probe | off | status only: ask Slack what the configured channel is and which bot scopes the app was granted, and report what it cannot receive. Two calls, no token printed, exit 0 whatever happens. |
--work-item REF | (all) | threads only: show one work item's conversation. |
--json | off | threads only: print the records as JSON. |
REF | required | records only: the work item whose ticket to read. |
--type TYPE | (both) | records only: context.added or decision.recorded; repeatable. |
--format FMT | markdown | records only: markdown (the text quoted back) or json (the rows). |
One thread per work item
The thread is the work item's (issue-312, decision-105), and it opens when the work item starts (issue-317, decision-107): the moment a start is accepted — a the-loop start comment, the-loop sessions start, the control plane, or the poller starting an authorized author's item — the dispatcher asks the channel to open the root that names the work item (the ref, and an Open on GitHub button when the ref has a link), before the checkout and the harness boot, and posts nothing else. Every event is then a reply into it: the ask, the graph's notifications, the mirrored comments. A work item that already has a thread keeps it; a refused start opens nothing; if the channel is down at start time the failure is recorded (channel.open_failed) and the first event opens the root lazily instead. Opening is done once, under a lock on the channel's state file, so the agent's session, the daemons and the poll watcher cannot open two threads for one work item between them; a reply that fails is recorded (channel.post_failed) and never followed by a second root. A thread a member started that became a work item keeps being that work item's thread. channels threads is the listing (its origin column says start, event or kickoff); channel.thread_opened is the event.
Notes
- Everything is observable as
bus.*andchannel.*events —the-loop events --typeslists them. Payloads carry ids and event types, never message text and never tokens. - A message is processed at most once, across restarts and across the two read transports: the per-thread and per-channel cursors are shared state (see state on disk). A kickoff whose issue creation failed is not retried — a retry could open a second issue — so the member posts again if they mean it.
pollandlistenrefuse (exit 1) rather than half-run when the channel is disabled or the tokens are missing — the same fail-closed posture as the rest of the surface.