Capability: channels
Every channel — GitHub, the Slack bot, the CLI, the next one — is a peer on one event bus: it subscribes to the events it wants, may publish the ones it is granted, renders each natively; and one channel, the ledger (GitHub), records everything that started elsewhere before anything acts on it.
What it is
The conversation layer, generalised (issue-309, decision-103). An event is one thing that happened, with a type from one catalog: the ask, the graph's notifications, the comments the ledger's ingress saw, the messages a channel read. The bus records an event on the ledger (when the catalog says so and it did not start there) and then hands it to every subscribed channel except its source. A channel is a named surface with a subscribe list (what it receives), a publish list (what a message on it may become), its own renderer, and — for channels that read — a pipeline that classifies each message into exactly one event type and drops what its grants do not cover. Distinct from the integrations layer (issue-109): an integration is a transport for one call; a channel is a conversation with state.
flowchart LR
ASK["the-loop ask (cli)"] --> BUS(("bus"))
HOOK["graph notify hook (loop)"] --> BUS
ING["ingress: router · poller (github)<br/>comment.agent · comment.human"] --> BUS
SL["slack: a reply, a button, a DM"] --> BUS
BUS -->|"1. record (envelope)"| GH[("GitHub — the ledger")]
BUS -->|"2. fan out by subscribe"| SLK["slack (Block Kit)"]
GH -->|"ingress judges the record<br/>as any human comment"| LOOP["gates · control · sessions"]Current behaviour
- One catalog. Every event type is a row of
channels/events.pydeclaring whether a channel may subscribe to it, may publish it, and whether the ledger records it. The config parser warns against it,the-loop channels statusprints it with ticks, and the channels options page lists it — a test pins the three together. Subscribable:session.awaiting_input, the six graph notifications (work-item-completenow fires from thecompletenode),comment.agent,comment.human,standing.started. Publishable:work-item.reply,gate.feedback,control.command,work-item.create. Recorded: the ask and the four publishable ones. - The bus is the only caller of a channel. WHEN any component publishes an event THEN the bus SHALL record it on the ledger first (if recorded and not from the ledger), then post it to every enabled channel whose
subscribenames its type and that is not its source. Every step SHALL be best-effort per channel: a failure is aPostResultand abus.record_failed/channel.post_failedevent, never an exception to the publisher. - The ledger.
channels.ledgernames the channel of record —github, the only value shipped; an unknown value is refused at load. A record is a comment carrying a machine-readable envelope (<!-- the-loop:event {…} -->: type, source, the actor's ids on every channel, timestamp). Four shapes: the ask's record is the question itself (marked); awork-item.replyrecord is the marked, quoted, scrubbed, keyword-defanged mirror; agate.feedback/control.commandrecord is unmarked, keywords intact, posted under the operator's credential with a visible attribution — so the ledger's own ingress classifies or executes it through the guards a typed comment goes through; awork-item.createrecord is the issue itself (unmarked, so it is armable; labelled from config only). WHEN the ledger's ingress sees an enveloped comment THEN it SHALL never re-publish it as acomment.*event (loop prevention across channels), and a marked one is dropped exactly as any marked comment is. - Identity in one place.
routing.authorizedUsersentries are people: a bare string is a GitHub login; a mapping names the person's id per channel (github,slack) plus an optionalname. Every login consumer reads exactly thegithubids; the Slack channel reads theslackids;channels.slack.authorizedUsersis gone (config version0.7.0, migrated bythe-loop migrate-config, which also renameseventstosubscribe). Empty stays fail-closed everywhere. WHEN a record names a person THEN the envelope SHALL carry every id the entry declares, resolved from config, never from the message. - Grants. WHEN a message arrives on a channel THEN the pipeline SHALL run map → drop-own → authorize → classify → grant → record → (deliver): a message outside a bound thread is
unmapped(unless it is a top-level kickoff candidate); a bot's is dropped; an unlisted member's is dropped, not recorded; classification is control keyword → open human gate → reply, and a type not inpublishis dropped asunpublishable-event, never downgraded. The default grant is[work-item.reply].gate.feedbackandcontrol.commandstop at the record — the ledger's ingress does the rest, on its next delivery or poll — and the graph'scomments_fromattributes an enveloped record to the person it names only when the real poster is authorized and the named login is too. - The gate is read through the dispatcher's own coupling, and "cannot tell" is left to the ledger (issue-321, decision-109). The pipeline reads whether the work item is parked at a human gate through the same
RoutingConfigthe daemons build their dispatcher from — the same control policy, control store, allow-list and registry — so under the default control policy an armed item at a gate reads as at a gate. The read has three answers: WHEN it says at a gate THEN the reply SHALL begate.feedback; WHEN it says not at a gate, or the coupling is off, THEN the reply SHALL bework-item.reply; WHEN the pipeline cannot tell — no session record, no checkout, no context, a fault — THEN, with thegate.feedbackgrant, the reply SHALL be recorded unmarked asgate.feedback(attributed as a reply, not as an answer to a gate the pipeline never saw) and delivered by nothing but the ledger's ingress, and without the grant it SHALL stay the marked mirror with direct delivery. A control keyword outranks every answer, as before.channel.reply_receivedcarriesgate: open | none | unknown. Before this the pipeline's reader had no control store, read no graph at all under the default policy, and turned every "cannot tell" into the marked record the gate never reads. - The comment mirror. WHEN the router or poller accepts a human comment (authorized or collaborator) THEN it SHALL publish
comment.human; WHEN it drops a marker-stamped, envelope-less comment THEN it SHALL publishcomment.agent— once per comment, first sight only on the poll path. A stranger's comment is published nowhere. - Content-rich notifications. The
notifyhook publishes with the work item's URL and, when the node names anartifact(requirements-approval→requirements.md,design-approval→design.md), an excerpt of it; it no longer skips whennotifications.eventsnames no role — the roles ride along as detail. The URL is derived from the work item's ref, which on GitHub Enterprise carries the hostintegrations.github.hostresolves (issue-311) — so the link a Slack member clicks and the comment the ledger wrote are on the same GitHub. - One thread per work item, rooted on the work item (issue-312, decision-105). WHEN the Slack channel receives an event for a work item that has no bound conversation THEN it SHALL open a root message naming the work item (its ref, and an Open on GitHub button when the ref has a link), bind it, and post the event as that thread's first reply; WHEN a conversation is bound THEN every event SHALL be a reply into it and the channel SHALL never post a second top-level message for the work item. Open-and-bind is exclusive per state file (
flockon a sibling lock), so the agent's session, the two daemons and the poll watcher open one thread between them; a reply that fails ischannel.post_failedand never a second root. A kickoff thread (a member's top-level message that became the work item) is that work item's conversation, no root opened; a standing session's thread follows the same rule. The conversation is a keyed record — work item → channel, thread, opened, origin (event|kickoff|legacy), permalink — in the local channel state, listed bythe-loop channels threads(--work-item,--json), counted bychannels status, and announced bychannel.thread_opened(ids only). A ref spelled with the default host (github:github.com/o/r#7) and without it (github:o/r#7) are one conversation; a state file from before issue-312 is backfilled from its newest binding on load. - The thread opens when the work item starts (issue-317, decision-107). WHEN a session is spawned for a work item — a
the-loop start/contribute/do/reviewcomment,the-loop sessions start, the control plane's start route, or the poller's presence spawn for an authorized author — THEN, before the workspace checkout and the harness boot, the dispatcher SHALL ask every enabled channel that has a conversation to open to open the work item's: the Slack channel posts the root alone (no reply) and binds it with originstart; the GitHub ledger opens nothing, because the issue is its conversation. A work item that already has a conversation SHALL keep it (a restart, a kickoff thread, a thread the first event already opened). A refused start — unarmed, unauthorized, spawn policy — SHALL open nothing, because the open sits on the spawn path behind every refusal. Best-effort by contract: a channel that raises or returns notsischannel.open_failed, the session spawns regardless, nothing is bound, and the next event opens the thread lazily as before. The first subscribed event is then the thread's first reply. The opener is injected into the dispatcher (channels.publishers.conversation_opener, reading the CLI config per call — both daemons and the core facade wire it, the facade with the config it was handed); a dispatcher built without one behaves as at 13.1.1. - Rendering is the channel's. The Slack channel posts Block Kit: a header (event, person, work item), the text capped at
maxCharswith the remainder behind the link, a context line atverbose, a link button whenever the event has a URL, and Approve / Request changes buttons for an approval-shaped event only whenread.mode: socketand thegate.feedbackgrant both hold. A press enters the pipeline as that member's reply carrying the button's text; an unrecognised value is plain text. - Kickoff. WHEN the channel holds
work-item.createANDkickoff.repois set AND an authorized member posts a top-level message THEN the ledger SHALL create the issue withkickoff.labels, the thread SHALL be bound to the new ref and told the link. The first read baselines the channel; a failed creation is not retried. - An accepted message is acknowledged on itself (issue-325, decision-111). WHEN an inbound Slack message — a thread reply, a button press, a kickoff — passes authorization, classification and the
publishgrant THEN, before the ledger record, the channel SHALL add the configuredreceivedreaction (channels.slack.reactions, default 👀) to that message; WHEN the pipeline's action has landed — awork-item.replyrecorded and delivered, agate.feedback/control.commandrecorded on the ledger, awork-item.createwith its issue opened and the thread bound — THEN it SHALL addcompleted(default ✅), anderror(default ⚠️) when it has not. A dropped message SHALL get no reaction. For a button press the target is the message carrying the button. Best-effort: posted with the bot token (reactions:write), a refused reaction ischannel.reaction_failedand never touches the record or the delivery, a missing token makes no call, and a name outside the emoji grammar is refused at load. On by default, mirroringrouting.reactions' contract with Slack's open palette rather than GitHub's fixed one. - Reads, tokens, state: as before —
pollorsocket(listennow also handlesblock_actionsand top-level messages), env-named tokens read at call time, bindings and cursors in<state.root>/channels/slack.json(plus achannel:<id>cursor). - Every step is observable:
bus.published,bus.recorded,bus.record_failed, thechannel.*types,channel.droppedwithunpublishable-event/kickoff-disabled/create-failed,channel.created,channel.thread_opened(originevent|kickoff|start),channel.open_failed,channel.reaction_addedandchannel.reaction_failed. Payloads carry ids and event types, never text.
Design
docs/specs/issue-325/design.md— thechannels.slack.reactionsblock,SlackBotChannel.react, the two calls on each accepted path of the pipeline, the socket handlers' channel.decision-111— the acknowledgment sits after the last refusal and before the record; its own block mirroringrouting.reactions' contract; Slack's palette; completed means the pipeline's own action landed.docs/specs/issue-321/design.md— the pipeline's graph reader as the dispatcher's own construction, the three-valued read, deferral to the ledger within the grant.decision-109— the reader is the dispatcher's coupling; "cannot tell" is a state; it defers to the ledger only within the grant; a reply's mirror keeps its marker.docs/specs/issue-317/design.md—openon the channel,open_conversationon the bus, the injected opener on the dispatcher's spawn path and its wiring through both daemons and the facade.decision-107— the open is a channel operation on the spawn path, not a bus event; before the checkout, not beside the announcement; best-effort; the ledger opens nothing.docs/specs/issue-312/design.md— the per-work-item conversation map and the siblingflock; the root-then-reply post;channels threads.decision-105— the root is the work item's; open-and-bind is exclusive; the conversation stays local; a failed reply never opens a second thread.docs/specs/issue-309/design.md— the catalog, the bus, the ledger's record shapes, identity, the classify-then-grant pipeline, the renderer, and the security design table (ten abuse cases, one negative test each).decision-103— through the ledger, never around it; grants are event types; identity entries keyed by channel; the person is recorded, the poster is the proof; buttons only where a press can arrive.docs/specs/issue-245/design.md— the Slack provider, the two read transports and the original inbound ordering, which this work item keeps.skills/the-loop/reference/collaboration.md§ Where questions go — how channels compose with the interaction mode and the marker rule.
History
| Work item | What changed | Links |
|---|---|---|
| issue-325 | The Slack channel acknowledges an accepted inbound message on the message itself: received (👀) after the last refusal and before the ledger record, then completed (✅) when the pipeline's action landed or error (⚠️) when it did not; a dropped message gets none; configured by channels.slack.reactions (on by default, Slack emoji names, "" skips a state), posted best-effort with the bot token's reactions:write, observable as channel.reaction_added / channel.reaction_failed. Before this, routing.reactions acknowledged only on GitHub and a Slack reply's only feedback was a later posted message | spec, decision-111, issue |
| issue-321 | An authorized gate answer from Slack now locks the gate under the daemon's default control policy: the pipeline's graph read is built from the same RoutingConfig the dispatcher's coupling is (control policy, control store, allow-list, registry), where before it had no control store and read no graph at all; the read is three-valued, and a gate the pipeline cannot read (no session record, no checkout, a fault) is recorded unmarked as gate.feedback for the ledger's ingress to judge when the channel holds that grant — attributed as a reply — and stays the marked mirror without it; channel.reply_received carries gate: open | none | unknown | spec, decision-109, issue |
| issue-317 | The Slack thread opens when the work item starts, not when its first event arrives: the dispatcher's one spawn path — which every way of starting converges on — asks every configured channel to open the work item's conversation (SlackBotChannel.open, root only, origin start, through bus.open_conversation) before the checkout; a bound work item keeps its thread, a refused start opens nothing, a channel failure is channel.open_failed and never touches the spawn, and the first event replies into the thread that already exists. Wired as an injected opener on the dispatcher (conversation_opener, config per call) by both daemons and the core facade | spec, decision-107, issue |
| issue-312 | The Slack thread is the work item's: the first event opens a root naming the work item (ref + link button) and every event, the first included, is a reply into it; open-and-bind runs under a flock on the channel state so the agent's session, the daemons and the poll watcher open one thread between them, and a failed reply never opens a second; the conversation is a keyed record (work item → channel, thread, opened, origin, permalink) backfilled from a pre-existing file, listed by the-loop channels threads and announced by channel.thread_opened; refs with and without the default host share one thread. Before this the root was whichever event arrived first, the binding a newest-wins scan, and four unlocked writers could open two threads or drop a binding | spec, decision-105, issue |
| issue-311 | The link every notification and ask carries names the work item's own GitHub: a ref the graph mints from ticketing.github now carries the resolved host (integrations.github.host, $GH_HOST, the checkout's remote), the ledger's gh api writes pass --hostname for it, and a kickoff repo may be [HOST/]OWNER/REPO with the bound ref carrying the host | spec, decision-104, issue |
| issue-309 | Made every channel a peer on one event bus with one ledger: a unified catalog with subscribe/publish/recorded flags; bus.publish as the only caller of a channel (the ask, the notify hook and both ingresses publish through it); the GitHub ledger with four record shapes and the envelope; identity declared once (routing.authorizedUsers person entries; channels.slack.authorizedUsers removed, events renamed subscribe, config version 0.7.0); per-channel publish grants — gate.feedback and control.command recorded unmarked for the ledger's ingress, work-item.create opening an issue from a top-level DM; Block Kit rendering with link and Approve buttons; comment.agent / comment.human mirrored into the bound thread; notifications carrying a link and an artifact excerpt; work-item-complete fired by the complete node. The five gaps @jc1993 named close as consequences | spec, decision-103, issue |
| issue-304 | Retired every Slack- and collaborator-related config surface that no code read, leaving one Slack surface (channels.slack) and two identity allow-lists (routing.authorizedUsers, channels.slack.authorizedUsers). Removed: the CLI config's top-level collaborators and notifications blocks (behind a versioned migration to 0.6.0, so an un-migrated config is refused rather than half-loaded) and collaborators.yaml's per-collaborator notifications sub-object (refused by the schema, with the replacement named in the message). collaborators.yaml now declares people and roles only; harness-config.yaml's notifications.events is unchanged and still gates the notify hook. Per-person routing stays deferred — the config no longer claims otherwise | spec, issue |
| issue-277 | A Slack thread can now carry a standing session instead of a work item: the binding key is standing:<name>, the mirror step is skipped (there is no ticket to mirror onto, recorded as channel.mirror_skipped) and the delivery goes to that session's pane. The bot drop, the authorized-member allow-list and the cursor advance are unchanged, and the bot still reads only threads it is bound to | spec, issue |
| issue-245 | Introduced the capability: the channel abstraction (events filter, verbosity, best-effort broadcast from the-loop ask), the Slack bot channel (slack-sdk, thread per work item, poll + Socket Mode reads), the authorize → mirror → deliver inbound pipeline with the work item as source of truth, the channels CLI verb, and the channel.* event types. In the same PR's review the owner converged Slack entirely onto this layer: the graph's notify hook broadcasts through channels and integrations.slack (the incoming webhook) was removed behind a versioned migration (0.5.0). | spec, decision-094, PR #267 |