Skip to content

channels ​

Operate the communication channels — the peers on the-loop's event bus, starting with the Slack bot (issue-245, issue-309).

bash
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 link

What it does ​

  • status prints the ledger, the resolved Slack configuration — with token presence only (set/unset plus the env var's name), never a value — how many people of routing.authorizedUsers can 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 where subscribe names it, and every publishable event with a tick where publish grants 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, or off; and since issue-338 the longMessages line beside maxChars — 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 first maxChars characters). Since issue-362 the read: line also names socket mode's reconcile cadence (read.catchUpSeconds), and a channel kind: line says what kind of conversation the configured id is — read from the id's own prefix, so status still calls nothing — with the bot scope and bot event that kind needs; a D… gets a [!] finding, because a Slack app imported from a pre-issue-362 manifest cannot receive a direct message's events at all. --probe replaces the guess with the measured answer: one conversations.info on the configured channel and one auth.test for 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, and status still exits 0 — a diagnostic never fails the command, and no token is ever printed. See which events your channel needs.
  • threads lists 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); --json prints the records. Since issue-389 a listen column says what the conversation hears: all for a direct message with the bot or a room declared --listen all, else mentions. It reads the state file only — no Slack call, no token — and prints ids, never a message's text.
  • records lists the records a channel wrote on a work item's ticket (issue-389): every marked, enveloped context.added (an @the-loop record-context snapshot) and decision.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 into context.md or a decision record; --format json prints the rows; --type (repeatable) keeps one kind. A session run the-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.
  • poll runs one synchronous read cycle: every bound Slack thread is checked for new replies and — with the work-item.create grant and a kickoff.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 when read.mode is poll — and, since issue-334, the reconciliation you may run beside a Socket Mode listener: the cursors are shared, so a cycle in socket mode reads only what the listener missed. Exit 1 when the cycle was skipped (channel disabled, read.mode: off, missing token), with the reason printed.
  • listen is the foreground form of the Socket Mode listener that the-loop start hosts inside the service whenever channels.slack.read.mode is socket (issue-334) — for service.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-loop slash command, acknowledged first and answered ephemerally through its response_url. On connecting it runs one catch-up read (the poll cycle) 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 as duplicate. Needs both tokens: the bot token to act, the app-level token (xapp-…, connections:write) to connect.
  • manifest prints 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-loop command — for Create New App → From a manifest. No config, no token, no network. The Slack integration guide walks the setup. Since issue-464:
    • --name NAME renames the app for its operator: display_information.name is NAME (35 characters at most) and the bot's display_name is its handle (lowercase, each run of characters outside a-z0-9._- turned into -). Every other key is the packaged manifest's. A name Slack would refuse exits 2.
    • --format json prints JSON. --link prints 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 as slack-app-manifest.json beside the resolved CLI config. Without --name it reuses the kept file's name, so a re-run after an upgrade rebuilds the file from the new release. It prints unchanged (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 --name is given (exit 2).
  • status also prints, since issue-389, a mentions: line — the mention is the address in every channel, delivered as app_mention over Socket Mode and needing the app_mentions:read bot scope, or nothing addressed can arrive when read.mode is not socket — a shortcuts: line naming the two message shortcuts and their callback ids, and a rooms: line counting the declared rooms and how many of them hear every message (--listen all). --probe measures the mention scope beside the kind's and reports its absence as a [!] finding naming the consequence.
  • status also prints a split 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.
  • status also prints a commands: line: which slash-command families this channel may run (work-item — control.command; instance — instance.command; standing — standing.command), or that none can arrive because read.mode is not socket.
  • status also prints a jira: block (issue-475): whether integrations.jira is configured, its site and deployment, each project with the repository it maps to (or mirror-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 the channels.jira mirror. 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 ​

FlagDefaultMeaning
(action)requiredOne of status, threads, records, poll, listen, manifest.
--name NAMEpackaged namemanifest 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|jsonyamlmanifest only: print the manifest as YAML or JSON.
--linkoffmanifest only: print Slack's one-click create-app URL instead of the manifest.
--write [PATH]offmanifest only: keep the manifest as JSON (default slack-app-manifest.json beside the CLI config), report what changed, print the link.
--probeoffstatus 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.
--jsonoffthreads only: print the records as JSON.
REFrequiredrecords only: the work item whose ticket to read.
--type TYPE(both)records only: context.added or decision.recorded; repeatable.
--format FMTmarkdownrecords 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.* and channel.* events — the-loop events --types lists 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.
  • poll and listen refuse (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.

Released under the MIT License.