Skip to content

Channels options ​

Options under channels — the surfaces the-loop talks on (issue-245, issue-309).

Every channel is a peer on one event bus (decision-103): it subscribes to the event types it wants to receive, it may publish the event types a message on it is granted to become, and it renders every event itself. One channel is the ledger — the channel of record every event is written to before any other channel sees it — GitHub, by default and for now. Distinct from integrations, deliberately: an integration is a transport for the-loop's own one-shot calls; a channel is a conversation.

yaml
routing:
  authorizedUsers:                          # identity, declared once — see routing options
    - github: octocat
      slack: U0456GHIJKL
channels:
  ledger: github
  slack:
    enabled: true
    botTokenEnv: THE_LOOP_SLACK_BOT_TOKEN   # xoxb-, chat:write + channels.history
    appTokenEnv: THE_LOOP_SLACK_APP_TOKEN   # xapp-, only for read.mode: socket (a gateway holds its own)
    channel: C0123ABCDEF                     # the channel the bot posts into
    subscribe: [session.awaiting_input, phase-approval-pending, comment.agent]
    publish: [work-item.reply, gate.feedback]
    verbosity: normal
    maxChars: 1500                           # and the threshold for longMessages
    longMessages: digest                     # digest | truncate — above maxChars
    room:
      style: agentic                         # agentic | classic — a work item's own room
    kickoff:
      repo: octocat/hello-world
      labels: ["the-loop: auto-execute"]
    read:
      mode: socket                           # poll | socket | off
      intervalSeconds: 30
      catchUpSeconds: 900                    # socket mode: how often it re-reads the threads
      splitCheckBeats: 2                     # socket mode: heartbeats per split check (0 = off)
    reactions:                               # acknowledge an accepted message on itself
      enabled: true
      received: eyes                         # 👀 the moment it is accepted
      completed: white_check_mark            # ✅ when the action landed
      error: warning                         # ⚠️ when it did not

The operator's map of every way to drive the loop from Slack — setup, the thread, the buttons, the kickoff, the /the-loop slash command, standing sessions, the control plane — is the Slack integration guide.

An asked question (the-loop ask) is recorded on the ledger first — the record is the question comment — then reaches every channel subscribed to session.awaiting_input; a thread reply from an authorized member becomes whatever the channel's publish list grants: session input by default, a gate answer or a control keyword or a new work item by grant — each recorded on the ledger and judged by the ledger's own ingress, never around it. Operate it with the-loop channels.

One thread per work item, the thread is the work item's, and it opens when the work item starts (issue-312, decision-105; issue-317, decision-107). The moment a start is accepted the dispatcher opens a root message that names the work item (the ref, with an Open on GitHub button) — before the checkout, on every enabled channel — and every event is a reply into it. A channel that is down at start time costs nothing but a channel.open_failed line: the first event then opens the root lazily. The root is opened once, under a lock on the channel's state, whichever of the-loop's processes gets there first; a thread a member started that became a work item (work-item.create) is that work item's thread. the-loop channels threads lists which thread carries which work item, and how it was opened.

A room a work item declared is the conversation itself (issue-378, decision-130). When an authorized user has run the-loop add-channel slack@#room on a work item (issue-375), the-loop opens no thread there: one message says the room is the work item's, and every event after it is a top-level message in the room, so the channel reads as the work item's timeline. The central channel is shared by every work item, which is why it keeps its threads. A reply under any of the-loop's messages in the room reaches the work item, as any message in the room does.

The ledger ​

ledger ​

  • Type: 'github' | 'jira'
  • Default: github

The channel of record. Every event that originates elsewhere is written down before any other channel receives it — as a comment carrying a machine-readable envelope naming the event type, the source channel and the person, or as the ticket itself for work-item.create. The ledger's ingress (the webhook receiver and the poller) is what acts on a relayed gate answer or control keyword, through the same guards a typed comment goes through.

Each event is recorded on the tracker of its own work item (issue-475): a Jira work item's events on its Jira ticket, a pull request's on GitHub. So this key decides one thing — the tracker work-item.create opens a ticket in. jira needs integrations.jira and a project with a repository named by the event; without integrations.jira it records on github and logs an error. An unknown value is refused at load.

Jira ​

A Jira issue as an output-only mirror of a work item (issue-475). An authorized user declares the room on the work item — the-loop add-channel jira@OPS-12 — and every subscribed event is posted there as a comment carrying the visible self-marker. It differs from Slack in two deliberate ways: there is no central fallback (a work item with no declared jira@ room is not mirrored), and there is no publish (nothing written on the mirror ticket ever reaches a session). It needs integrations.jira; the room's project must be listed under integrations.jira.projects, and a project listed without a repository is mirror-only — a room, never a work item. The full setup is in Channels — Jira.

yaml
integrations:
  jira:
    site: acme.atlassian.net
    deployment: cloud
    api: {emailEnv: [JIRA_EMAIL], tokenEnv: [JIRA_API_TOKEN]}
    projects:
      OPS: {}                     # no repository: mirror-only
channels:
  jira:
    enabled: true
    subscribe: [work-item.started, phase.started, phase.completed,
                session.awaiting_input, work-item.closed]
    verbosity: normal

jira.enabled ​

  • Type: boolean
  • Default: false

Default off: configuring the Jira integration never becomes consent to mirroring into a Jira ticket. Enabled without integrations.jira, the channel is not loaded and an error is logged (fail closed).

jira.subscribe ​

  • Type: string[]
  • Default: ["session.awaiting_input"]

The event types this channel receives, exactly as slack.subscribe — for example work-item.started, phase.started, phase.completed, session.awaiting_input and work-item.closed. the-loop channels status prints the catalog. A name outside it is kept, with a warning.

jira.verbosity ​

  • Type: 'quiet' | 'normal' | 'verbose'
  • Default: normal

quiet is one line and the work-item link; normal adds the event text; verbose adds the context detail. Strict supersets, as on Slack.

Slack ​

slack.enabled ​

  • Type: boolean
  • Default: false

Default off: enabling the daemons never becomes consent to posting into (or reading from) a Slack workspace. A malformed section also resolves to disabled, loudly — fail closed, never half-enabled.

slack.botTokenEnv ​

  • Type: string
  • Default: THE_LOOP_SLACK_BOT_TOKEN

The environment variable holding the bot token (xoxb-…, needing chat:write to post, channels:history to read — groups:history in a private channel — reactions:write to acknowledge a reply on the reply itself, commands for the slash command, and the three read-only scopes that turn a name into an id — channels:read, groups:read, users:read; the packaged app manifest declares them all). The config names the variable, the token is read from the environment at call time, and the value never appears in config, state files, channels status output or the event log.

Behind a gateway that posts for its instances (slack.post.enabled, the default — issue-519) the token is optional here: unset, the instance's Slack calls go through the gateway, which holds the app's bot token already. Set, it is used first, gateway or not. With neither, the-loop start warns once and channels status prints posting: unavailable with the reason. The variable can be set in the shell or in a .env file the config names (env.file), loaded when each process starts.

slack.appTokenEnv ​

  • Type: string
  • Default: THE_LOOP_SLACK_APP_TOKEN

The environment variable holding the app-level token (xapp-…, scope connections:write) that the-loop channels listen needs for Socket Mode — the no-polling read transport, and the only one that can receive a button press. Unused in poll mode.

slack.channel ​

  • Type: string
  • Default: ""

The Slack channel the bot posts into, by name (#the-loop, or bare) or by conversation id (C… — copy it from the channel's details pane). The bot must be a member either way. Empty disables posting, with a recorded reason per attempt.

A name is resolved to an id once per process, over a workspace directory cached under <state.root>/local/slack-directory.json and refreshed at most hourly, so every outbound path reads one id and no message costs a lookup. Resolving needs the app's channels:read (public) / groups:read (private) scopes — the packaged app manifest declares both — and a name that resolves to nothing raises rather than posting somewhere else.

An id is the stronger form: it needs no scope, no lookup and no cache, and it survives a rename. Prefer it in a config file that is written once and read forever; use a name when you want the file to read the way people talk.

slack.subscribe ​

  • Type: string[]
  • Default: ["session.awaiting_input"]

The event types this channel receives. Was events before issue-309 — the-loop migrate-config renames it. The names come from one catalog (printed with subscription ticks by the-loop channels status):

EventFires when
session.awaiting_inputan agent asked a human a question (the-loop ask) and is waiting — the default subscription
decision-pendingthe graph reached a point where a human decision or opinion is genuinely required
phase-approval-pendinga spec-chain phase (requirements, design + testing plan) is ready for its human gate — the message carries a link and an excerpt of the artifact
pr-review-pendinga pull request delivering the work item is ready for human review
security-sign-off-pendingthe work item's risk tier requires a named human security sign-off
conflict-escalatedthe loop hit a genuine block, logged the conflict and escalated once
work-item-completethe work item reached complete (fired since issue-309 — a channel that had subscribed starts receiving it)
phase.starteda phase of the work item's loop began — the node carrying the next loop:<phase> label was entered; published by the runtime on every walk of every graph, so it needs no notify hook, and a human node's message says it is waiting on a person (issue-378)
phase.completeda phase ended — the loop left its last node on a satisfied outcome, or finished at a terminal node; carries the outcome and the node entered next (issue-378)
phase.progressthe loop stepped to a new node within the current phase — the review chain walking self-review → critic-review → security-review is the loud case; published by the runtime, not recorded on the ledger, and in an agentic room it edits that phase's one message in place so a long phase shows where it is instead of going silent (issue-393)
work-item.closedthe work item ended on the ledger — its issue was closed, or the pull request that is the work item merged or closed; published before the item's collaboration channel is forgotten, so a room hears its own ending (issue-378). A delivering pull request's end fires nothing
comment.agentthe agent's own comment landed on the work item (marker-stamped): the requirements summary, the phase checklist, a review note
comment.humana human comment the ledger accepted — an authorized user's or a work-item collaborator's. A stranger's comment is relayed nowhere
standing.starteda standing session came up and opened its thread

None of the three lifecycle rows is recorded on the ledger: the loop:<phase> label and the closure are its record. A channel that subscribes to nothing new sees nothing new.

A name outside the catalog is kept but warned about (a custom process graph may fire a custom notify event; a typo would otherwise fail silently). A test pins the catalog to this table and to the harness config's notification taxonomy.

Subscribe to comment.agent alone and the bound thread carries what the agent wrote and no human's words; add comment.human and it carries the thread. A record the bus itself made — a reply's mirror, a relayed gate answer, the ask — is never re-published as a comment event: the channel that raised it already has it.

slack.publish ​

  • Type: string[]
  • Default: ["work-item.reply"]

What a message on this channel may become — the channel's authority, per event type (decision-103 D2). A message is classified into exactly one type, in a fixed order, and a type not listed here is dropped, never downgraded (recorded as channel.dropped with reason: unpublishable-event): a control keyword typed on a channel without the grant does not reach the agent as prose either.

GrantA message becomes it whenWhat happens
work-item.replynone of the below appliesmirrored onto the work item as the-loop's own marked comment (quoted, scrubbed, keywords defanged) and delivered into the waiting session — 12.1.0's behaviour, the default
gate.feedbackthe work item's graph is parked at a human gate — or the pipeline cannot tell (no session record, no checkout, a read fault)recorded on the ledger as an unmarked comment under your own credential, with the envelope and a visible "answer from slack:U…" attribution (a "reply from" when the gate could not be read); the ledger's ingress then classifies it exactly as a typed approval — with the graph it actually keeps — and the artifact's approvedBy names the person the envelope names
control.commandthe text carries a control keyword — typed, or pressed as the Execute / Start button (issue-337)recorded the same way, keyword intact; the ledger's ingress executes it through the same named-actor control seam. With read.mode: socket or gateway this grant also renders the Execute button on the phase-selection checklist and the Start button on a kickoff's reply, each carrying the configured keyword as its value
work-item.createthe message is top-level in the configured channel, or a /the-loop new slash command (issue-378)an issue is created in the repository the message named, else kickoff.repo, with kickoff.labels — needs the grant, and a target inside the declared repositories. With read.mode: socket or gateway this grant also renders the repository picker on a message that named none the-loop knows (issue-349)
instance.commanda /the-loop status, restart or upgrade slash commandthe core facade the-loop status / the-loop restart [--with-upgrade] run — answered ephemerally; not recorded (no ticket), the event log is the trail (issue-334)
standing.commanda /the-loop standing list|start|stop|restart <name> slash commandthe same core verb the-loop standing <verb> runs; not recorded
context.added@the-loop record-context in a thread (or the Add as the-loop context message shortcut), from an authorized member or one of the work item's collaborators (issue-389)the thread is snapshotted onto the work item as a marked record (quoted, scrubbed, keywords defanged, the envelope naming the person) and delivered into the session with a preset frame; the session appends it to docs/specs/<id>/context.md. Needs read.mode: socket or gateway — the mention arrives as app_mention
decision.recorded@the-loop record-decision <text> (or the Record the-loop decision shortcut and its modal), from an authorized member onlyrecorded the same way — marked, attributed by its envelope, never read as a gate answer — and delivered into the session, which writes docs/decisions/decision-<nnn>.md and its index row

A /the-loop <keyword> <work-item> slash command is control.command too — the same unmarked record on the ticket, the same execution by the ledger's ingress — so one grant covers a keyword typed in the thread and a keyword sent as a command. The ordering (keyword → gate → reply) means an approval word inside a control comment never becomes a gate answer. The gate is read through the dispatcher's own coupling — the same control policy, control store and registry the ingress reads with (issue-321, decision-109) — and the read has three answers. At a gate is a gate answer. Not at a gate, or routing.graph.enabled: false, is a reply with direct delivery. Cannot tell — no session record, a record with no checkout, a read fault — defers to the reader that can: with this grant the reply is recorded unmarked and the ledger's ingress judges it; without it the reply is the marked mirror it always was, so a channel that may not answer gates still never does. The event log says which (channel.reply_received, gate: open | none | unknown). A relayed gate answer or control keyword moves the loop on the ledger's next ingress: a webhook delivery, or one poll interval. A name the catalog does not mark publishable is ignored with a warning — a typo can never widen what a chat message may do.

slack.verbosity ​

  • Type: 'quiet' | 'normal' | 'verbose'
  • Default: normal

How much of an event each message carries: quiet is the header plus the link button; normal adds the text (and, for an approval, the artifact excerpt); verbose adds the context detail. Strict supersets — turning it down never changes the words, only how many of them there are.

slack.maxChars ​

  • Type: integer (minimum 200)
  • Default: 1500

How much text one Slack message carries — a comment body, an artifact excerpt — and the threshold above which longMessages (below) applies (issue-338). At most Slack's own 3000-character section limit. This is the lever for a small screen: a phone reader turns it down, and the digest fits the message to it.

slack.longMessages ​

  • Type: 'digest' | 'truncate'
  • Default: digest

What happens to a text section longer than maxChars (issue-338, decision-118).

digest — a structural digest the channel computes, with no model involved: the first question in the text (or, failing one, the sentence that says reply …) goes first, in bold; a list becomes numbered lines (1., 2., … — GitHub task boxes drawn as ☑ / ☐); every code fence, markdown table and stack trace is replaced in place by a pointer (⟨code: 12 lines⟩, ⟨table: 4 rows⟩, ⟨stack trace: 9 lines⟩); an absolute path of three or more segments loses its head (…/channels/slack.py); the rest follows in the author's order until the budget is spent; the cut falls on a sentence (a clause, or a word, only when no sentence fits); and one closing line links the full text — … full text: GitHub — whenever anything was cut, left out or replaced. Every sentence in a digest is one the author wrote: the digest reorders, numbers and points; it never paraphrases.

truncate — the first maxChars characters and a … (N more characters — see the link) note — 13.10.0's behaviour.

A text at or under maxChars is posted whole either way, in the author's order, with no pointer and no closing line. Whatever the length, GitHub markdown is drawn as Slack mrkdwn (**bold** → *bold*, headings, links, task boxes, bullets) and an HTML comment — the-loop's own markers included — is removed: that changes how the words are drawn, never which words are there. The Slack guide shows a checklist before and after.

slack.room.style ​

  • Type: 'agentic' | 'classic'
  • Default: agentic

How a work item's own room reads (issue-393) — a room being a channel a single work item was declared into (add-channel). This does not affect a shared or central channel, which keeps the header rendering.

agentic — one message per meaningful moment, in the-loop's first-person voice with one state emoji and no machine header. Consecutive lifecycle events collapse into one transition, a human gate is announced once (the message carrying its buttons), a phase's progress edits a single message in place, and an acknowledgement threads under the message it answers. This is the room a person can follow on a phone.

classic — the pre-issue-393 rendering, byte-for-byte: every event a top-level message with the event-type/ref header. The escape hatch, and the compatibility baseline.

slack.kickoff.repo ​

  • Type: string
  • Default: ""

The [host/]owner/repo a top-level message becomes an issue in when the message does not name one — the [HOST/]OWNER/REPO grammar, so a GitHub Enterprise deployment names its host and the bound ref carries it (issue-311).

Since issue-341 this is the fallback, not the requirement. A kickoff message may start with a repository prefix:

slim-gym: flaky teardown in the batch runner

The prefix is resolved against the repositories you declared — the top-level repositories, the one list every ingress reads (issue-348) — as a bare repository name, an owner/repo, or a host/owner/repo; the prefix is stripped before the issue is composed, and kickoff.labels applies whichever repository is chosen. Nothing is inferred beyond that list (decision-120):

Since issue-349 a message that names no repository the-loop knows is asked about rather than refused, wherever a press can be received (decision-122). The rule is one sentence — if a pick could answer it, ask; otherwise refuse — and the column below says which you get:

The first lineWhat happens
a prefix naming exactly one declared repositorythe issue is opened there — you answered the question by typing it
owner/repo: naming none, or a bare name matching severalasked: a reply offering your declared repositories as buttons or a select menu (a bare name matching several narrows the options to those). Refused as before where a press cannot be received — see below
a bare word matching none (fix: …)not treated as a prefix; the message goes to this repo unchanged
a prefix and nothing after itrefused — no pick puts words in an empty message
no prefix, this repo emptyasked, offering every declared repository
no prefix, this repo not in repositoriesasked — it points at a declared repository, it does not declare one (issue-348)

A question is only asked where a press can be received and there is something to offer: read.mode: socket or gateway — the same rule the buttons follow, because Slack delivers an interactive payload only to an acknowledging Socket Mode connection, this instance's own or the gateway's — and at least one declared repository. Without either, the kickoff is refused with the text it has always used, and the-loop channels status names the reason on its kickoff: line. No grant beyond the work-item.create the kickoff already needs.

A pending question is held under the message's own ts in the channel state's pending map (state): invisible after 24 hours, at most 50 outstanding, answerable once (a double tap opens one issue) and only by the member who posted the message. Nothing is created, recorded or bound until the pick lands, and the grant is re-read when the press arrives — revoke work-item.create and the outstanding questions stop being answerable with it. A press from an authorized member that cannot be honoured is answered on the question message itself, with the options left in place; a press from anyone else edits nothing at all.

So work-item.create with an empty repo is now a valid configuration — prefix-only kickoff, or no prefix at all and the question does the work — rather than a dead one. Every refusal and every question reaches only a member on routing.authorizedUsers: an unlisted member is still dropped in silence and told nothing, including which repositories exist.

The first read after the grant is turned on baselines the channel: nothing already there becomes an issue. The created issue's body carries the envelope and no self-authored marker (it must be armable); the thread is bound to the new work item and told the link.

slack.kickoff.labels ​

  • Type: string[]
  • Default: []

Labels applied to the created issue — from here and only here, never from the message. Add every label in routing.autoExecuteLabels to arm the item for the-loop start, or leave it empty to file the issue and stop.

slack.read.mode ​

  • Type: 'poll' | 'socket' | 'gateway' | 'off'
  • Default: poll

How messages come back. poll: the long-running daemons fetch new thread replies (and, with the kickoff grant, new top-level messages) on a background thread, and the-loop channels poll runs one cycle for cron or daemon-less deployments. socket: the-loop channels listen receives them push-fashion over Socket Mode — no polling, no inbound HTTP endpoint — running one catch-up read over the bound threads the moment it connects (issue-334), so what was posted while no listener was connected is processed once from the shared cursors (the-loop channels poll runs the same cycle on demand in this mode too, as a reconciliation). socket and gateway are the only modes that receive a button press, so Approve / Request changes buttons are rendered only there (and only with the gate.feedback grant), and so are the Execute / Start buttons (only with the control.command grant — issue-337): a button nobody can receive is worse than none. They are also the only modes the /the-loop slash command can arrive in. A processed press is written back onto the pressed message (the buttons replaced by the outcome line); the-loop channels status prints the steps a configuration still needs before a press can arrive. See the Slack guide's buttons section. A change to this key takes effect in the running service without a restart: the hosted listener is stopped within a few seconds when the mode leaves socket, and started when it returns (issue-395).

gateway (issue-503) is for an instance behind a gateway. The instance never opens a Socket Mode connection of its own and starts no poll reader. Slack arrives only through the gateway client, and everything socket offers is available under the same grants: the buttons, the kickoff picker, the slash command, mentions and the message shortcuts. The app-level token is the gateway's, so the instance does not need one. The mode needs a gateway block. Without one it reads nothing and renders no button, and the error log and the-loop channels status both say why. It never falls back to a socket, so deleting the block cannot put a second consumer on the gateway's app.

A 20.3.0 config that set socket beside a gateway block keeps working: the block still keeps the listener off. It is flagged in the log and in channels status, and the-loop migrate-config rewrites it to gateway. Whatever the mode, the-loop channels listen refuses to open a socket while a gateway block is present.

off: nothing is read. An unknown value resolves to off with a warning — never to a reading mode by accident.

slack.read.intervalSeconds ​

  • Type: integer (minimum 5)
  • Default: 30

Poll-mode cadence. A cycle with no bound threads and no kickoff grant makes no API call at all.

slack.read.catchUpSeconds ​

  • Type: integer (minimum 0)
  • Default: 900

Socket-mode reconcile cadence (issue-362): how often the listener re-reads every bound thread and the kickoff cursor on top of the messages Slack pushes to it and the one read it runs at connect. It is the ceiling on how late a missed envelope can be, whatever missed it — a message event the Slack app is not subscribed to (see which events your channel needs), a Socket Mode reconnect gap, an acknowledgement that raced a restart. Before issue-362 the cycle ran only at connect, so a missed message waited for the next daemon start.

0 means connect-only, which is 16.0.1's behaviour. A non-zero value below 60 is raised to 60 with a warning: a reconcile is a safety net, not a second poll transport, and each cycle costs one conversations.history plus one conversations.replies per bound thread. Read only in socket mode — poll already re-reads every intervalSeconds. A cycle processes exactly what the shared cursors say is new, so nothing is delivered twice.

slack.read.splitCheckBeats ​

  • Type: integer (minimum 0, maximum 5)
  • Default: 2

How many nonce heartbeats the listener posts into channel to measure its own share of the inbound traffic (issue-413) — once at connect, and again on every catchUpSeconds reconcile. Each heartbeat is deleted again as soon as the window closes.

The failure it exists to find has no other symptom. When a second Socket Mode consumer holds this app-level token — a stale channels listen, an abandoned workspace's instance, a host nobody remembers — Slack load-balances the app's envelopes across the open connections, and roughly half of everything inbound lands on the other one: mentions, button presses, select-menu picks, slash commands. On this instance the loss leaves no trace at all, because the envelope was never offered to it, and Slack's client still answers the member's press with {"ok": true}.

A check that hears back fewer beats than it posted records split-suspected, emits channel.split_suspected at warning (once, then every sixth consecutive short check), and puts a line in the-loop status and the-loop channels status. It is evidence, not proof: Slack exposes no API that lists an app's connections, so a consumer that was idle during the window does not show, and a beat lost for any other reason looks the same. That is why the report reads the last eight verdicts rather than the latest one — under a real split at two beats, one check in four comes back clean.

0 turns the check off and posts nothing. A value above 5 is lowered to 5: a check is a measurement, not a broadcast, and confidence is bought more cheaply by waiting for the next cycle than by posting more messages into a room people read. Read only in socket mode — poll mode has no connection to split.

The slash command ​

/the-loop (issue-334, decision-116) is the channel's third inbound shape — the one for things that have no thread to type into: a work item that has not started, a standing session that is not running, this instance itself. It arrives over Socket Mode only (read.mode: socket, or gateway when the gateway holds the connection; Slack needs an acknowledgment within seconds, which a poll cycle cannot give), is judged by the same slack ids of routing.authorizedUsers — an unlisted member gets no answer — and each verb family needs its grant in publish:

VerbsGrantWhere it goes
<keyword> <work-item> [@login] [instance:<name>] — start, stop, pause, resume, execute, contribute, do, review, cleanup, add-collaborator, remove-collaboratorcontrol.commandthe ledger, as an unmarked comment composed from the configured keyword; the ingress executes it. The work item (#N against kickoff.repo, owner/repo#N, github:…, a URL) must be in a repository this instance is configured for (the top-level repositories) or one it already manages
new [<repo>:] <title> (alias create; more lines are the body)work-item.createthe ledger opens the issue exactly as a top-level kickoff message would — the first line's <repo>: prefix resolved against the declared repositories, else kickoff.repo, with kickoff.labels — then the work item's thread is opened in the home channel and told the link with the Start button; the answer carries the link and the channel. An unresolved or ambiguous repository is refused with the kickoff's own text and nothing is created — there is no message to hold a picker on (issue-378)
status · restart · upgradeinstance.commandcore.lifecycle — what the-loop status and the-loop restart [--with-upgrade] run
standing list · standing start|stop|restart <name>standing.commandcore.standing — what the-loop standing <verb> runs

The answer is ephemeral, through the command's response_url (Slack's own host only). The app manifest the-loop channels manifest prints declares the command; the guide has the full grammar and the limits. No configuration key is added: the grants and read.mode are the switches, and the-loop channels status prints a commands: line saying which families this channel may run, or why none can arrive.

Acknowledgments ​

slack.reactions.enabled ​

  • Type: boolean
  • Default: true

Acknowledge an accepted inbound message with a reaction on that message (issue-325, decision-111) — the channel-side mirror of the ticket's routing.reactions. The moment a thread reply, a button press or a top-level kickoff message passes authorization, classification and the publish grant — and before the ledger record — the bot adds received to it; when the pipeline's action has landed it adds completed, or error when it has not. So the operator replying from a phone sees 👀 within a poll interval (instantly over Socket Mode) and ✅ / ⚠️ when the-loop is done with the message, without opening GitHub.

Landed is defined per event type, and only over what the pipeline itself does: a work-item.reply is complete when it is recorded and delivered into the session; a gate.feedback or control.command when its unmarked record reached the ledger — the ledger's ingress acts on it afterwards, and that outcome is not reported here; a work-item.create when the issue exists and the thread is bound to it. A dropped message — a bot's, an unlisted member's, an unpublishable type, an unmapped thread — gets no reaction: a drop leaves no mark on the channel, as it leaves none on the ticket.

Best-effort by contract: the reaction is posted with the bot token (which needs the reactions:write scope for this and nothing else), a refused reaction is one channel.reaction_failed event and never affects the record or the delivery, and a missing token makes no call. Reaction-only, no text. Set false to opt out; the channel's own enabled: false already means nothing is read, so nothing is acknowledged.

slack.reactions.received ​

  • Type: Slack emoji name, or ""
  • Default: eyes (👀)

Added when the message is accepted, before the record. "" skips this state.

slack.reactions.completed ​

  • Type: Slack emoji name, or ""
  • Default: white_check_mark (✅)

Added when the pipeline's action landed. "" skips this state.

slack.reactions.error ​

  • Type: Slack emoji name, or ""
  • Default: warning (⚠️)

Added when the record could not be written, the reply could not be delivered, or the issue could not be created. "" skips this state.

Names are Slack emoji names without colons — built-in (eyes, +1, tada) or a workspace's custom emoji — matching ^[a-z0-9_+-]{1,100}$; surrounding colons are stripped, and a name outside the grammar is refused at load with a warning and that state skipped. Slack's palette is open where GitHub's is fixed to eight, which is why the defaults here can be the ✅ that routing.reactions cannot offer.

Who may speak ​

channels.slack.authorizedUsers is gone (issue-309). The Slack member ids the channel acts on are the slack ids of the person entries under routing.authorizedUsers — identity is declared once, with each person's id on every channel, and read per channel. Empty denies every reply, button press and kickoff (fail closed).

Released under the MIT License.