Skip to content

Slack integration ​

Slack is a channel for the-loop: a peer on its event bus that receives the events you subscribe it to, holds one thread per work item, and lets an authorized member drive the loop back — as a reply, a button press (Approve, Request changes, and since issue-337 Execute and Start), a new work item, or, since issue-334, a slash command. GitHub stays the ledger: everything that starts in Slack is recorded on the work item first, and the loop reads it from there (decision-103). This page is the operator's map — every way to get work done from Slack, what each needs, and where the limits are.

Setting it up ​

1. Create the Slack app from the manifest ​

the-loop ships the app definition Slack imports — every scope, event subscription, the Socket Mode switch and the /the-loop command. Each operator creates their own app, because its tokens are what their daemon runs on, so give it a name of your own. Two operators in one workspace then get two distinguishable bots.

One click (issue-464, and what /the-loop:init-cli walks you through):

bash
the-loop channels manifest --name "the-loop-dana" --write

This keeps the renamed manifest as slack-app-manifest.json beside your CLI config (.the-loop/ or ~/.the-loop/) and prints a link, https://api.slack.com/apps?new_app=1&manifest_json=…. Open it, pick the workspace, confirm Create, then Install to Workspace. Building the link needs no token and makes no network call.

  • --name sets the app's name (35 characters at most). The bot's @handle is derived from it: lowercase, with every run of other characters turned into -, so Dana's Loop becomes @dana-s-loop. Everything else is the packaged manifest. The slash command stays /the-loop. When two apps in a workspace declare it, Slack asks which one you mean.
  • --link prints only the link, and --format json prints the manifest as JSON.
  • Run --write again, with no --name, after upgrading the-loop (or let /the-loop:upgrade-the-loop do it). It rebuilds the file from the new release under the kept name and names the bot scopes and events that changed. Then paste the file over your app's App Manifest (see 1b). Clicking the link again would create a second app.

By hand: print the manifest with the-loop channels manifest, or copy it from here; then at api.slack.com/apps choose Create New App → From a manifest, pick the workspace, and paste it.

yaml
# The Slack app the-loop's channel needs — Slack's own app-manifest format
# (https://api.slack.com/reference/manifests). Import it once: api.slack.com/apps
# → Create New App → From a manifest → paste this file. Print it with
# `the-loop channels manifest`. After the import, mint the app-level token
# (Basic Information → App-Level Tokens, scope connections:write) for Socket Mode
# and install the app to the workspace for the bot token; export both under the
# names channels.slack.botTokenEnv / appTokenEnv name. (issue-334)
# One click instead (issue-464): `the-loop channels manifest --name "<your app>"
# --write` keeps a renamed copy beside the CLI config and prints Slack's
# prefilled create-app link.
display_information:
  name: the-loop
  description: Drive the-loop from Slack — one thread per work item, a slash command for the rest.
  background_color: "#1f2937"
features:
  bot_user:
    display_name: the-loop
    always_online: false
  slash_commands:
    - command: /the-loop
      description: "Drive the-loop: start a work item, check the instance, standing sessions"
      usage_hint: "help | start #123 | status | upgrade | standing start <name>"
      should_escape: false
  # The two acts a room asks for, from a message's ⋯ menu (issue-389): each is
  # exactly the typed mention — `@the-loop record-context` on that message's
  # thread, `@the-loop record-decision` through a modal — under the same
  # allow-list and the same grants. Delivered over Socket Mode like a button.
  shortcuts:
    - name: Add as the-loop context
      type: message
      callback_id: "the-loop:record-context"
      description: Snapshot this thread onto the work item as context
    - name: Record the-loop decision
      type: message
      callback_id: "the-loop:record-decision"
      description: Record what this message decided, with a kind and a rationale
oauth_config:
  scopes:
    bot:
      - chat:write          # post into the channel and its threads
      - channels:history    # read thread replies and kickoffs in a public channel
      - groups:history      # the same in a private channel
      - im:history          # the same in a direct message with the bot (issue-362)
      - mpim:history        # the same in a group direct message (issue-362)
      - reactions:write     # acknowledge an accepted message on itself (issue-325)
      - commands            # the /the-loop slash command (issue-334)
      # Name resolution (issue-375): a person types #tmp-issue-375 and @dana;
      # Slack's API takes C0123ABCD and U0456GHIJ. These three scopes are READ
      # ONLY and are what turns one into the other — for channels.slack.channel,
      # for `the-loop add-channel`, and for routing.authorizedUsers[].slack.
      # Without them the-loop still works; it just needs ids everywhere.
      - channels:read       # resolve a PUBLIC channel name to its id
      - groups:read         # the same for a private channel the bot is in
      - users:read          # resolve @handle to a member id
      # The mention is the address (issue-389): a message meant for the-loop
      # carries @the-loop and arrives as the app_mention event; a message.*
      # event is input only in a DM with the bot or in a room an authorized
      # user switched to `--listen all`.
      - app_mentions:read   # hear @the-loop in any channel the bot is in
      # A screenshot or a voice note on a message (issue-416): READ ONLY, and
      # what lets the-loop fetch a file the bot can already see — the image for
      # the session to read, the captions Slack itself produced for a voice
      # clip. Without it a file is named and linked, never fetched.
      - files:read
settings:
  event_subscriptions:
    bot_events:
      - message.channels    # a public channel
      - message.groups      # a private channel
      - message.im          # a direct message with the bot (issue-362)
      - message.mpim        # a group direct message (issue-362)
      - app_mention         # @the-loop, in any of them (issue-389)
  interactivity:
    is_enabled: true        # Approve / Request changes, Execute / Do / Start buttons (read.mode: socket or gateway)
  org_deploy_enabled: false
  socket_mode_enabled: true # `the-loop channels listen` — an outbound connection, nothing exposed
  token_rotation_enabled: false

Names instead of ids (issue-375)

The last three scopes are read-only and buy one thing: you write #the-loop, @dana and the-loop add-channel slack@#tmp-issue-375 instead of C0123ABCD, U0456GHIJ and a trip to the channel details pane. the-loop resolves each once, caches the workspace directory under <state.root>/local/, and stores and routes on the id — so a rename changes nothing and no message costs a lookup.

Leave them out and the-loop works exactly as before; it just needs an id everywhere, and says which scope is missing when it meets a name. An id is also the stronger form for routing.authorizedUsers[].slack: a handle authorizes whoever holds it, while a member id names one person for good.

The manifest is the "reusable, importable" definition the ticket asked about — Slack's own format, checked into the-loop, the same for every workspace. (It is not a Workflow Builder workflow; why.)

The app's icon. Slack's manifest format carries no icon, so set it once by hand: Basic Information → Display Information → App icon & Preview, and upload docs/assets/the-loop-logo-1024.png — the-loop's mark (issue-398) on its paper ground, at the square size Slack expects. It is the same mark the README and the docs site carry, so a thread in Slack and the repository read as one thing.

Upgrading the app you already have (1b) ​

An app created for an earlier the-loop (issue-245 / issue-309 — thread replies and buttons, no command) needs six things added: the commands scope and the /the-loop command, the private-channel scope and event (groups:history, message.groups), the direct-message scopes and events (im:history / mpim:history, message.im / message.mpim — issue-362), the three read-only name-resolution scopes (channels:read, groups:read, users:read — issue-375), the mention scope and event plus the two message shortcuts (app_mentions:read, app_mention, Add as the-loop context / Record the-loop decision — issue-389; the manifest above carries all of them, and until an installed app has the scope and the event, nothing typed in a channel or thread reaches the-loop — channels status --probe and the listener's connect-time probe say so), and — if it never used Socket Mode — Socket Mode itself. Two ways, pick one:

  • Replace the manifest (recommended, one step). Regenerate yours with the-loop channels manifest --write (add --name "<your app's name>" the first time, if you created the app before issue-464), then at api.slack.com/apps open the app → App Manifest → paste slack-app-manifest.json over the existing one → Save Changes. Slack shows the diff and, because scopes changed, asks you to reinstall the app to the workspace — do it (Install App → Reinstall). Pasting the packaged manifest instead replaces your app's display_information.name and bot_user.display_name with the-loop.

  • By hand, in the app's settings, then Reinstall:

    PageAdd
    OAuth & Permissions → Bot Token Scopescommands, groups:history, im:history, mpim:history, channels:read, groups:read, users:read, app_mentions:read (and reactions:write if the app predates issue-325)
    Socket ModeEnable Socket Mode (if not already)
    Slash Commands → Create New Commandcommand /the-loop, any description and usage hint; no Request URL is needed in Socket Mode
    Event Subscriptions → Subscribe to bot eventsmessage.groups, message.im, message.mpim (beside the existing message.channels), and app_mention — the event every typed mention arrives as
    Interactivity & Shortcutson (it already is if the buttons worked)
    Interactivity & Shortcuts → Create New Shortcuttwo shortcuts On messages: Add as the-loop context, callback id the-loop:record-context; Record the-loop decision, callback id the-loop:record-decision. The names are yours to change (Slack allows at most 24 characters); the callback ids are not

After either path: the bot token stays the one you have unless Slack issues a new one on reinstall (it shows it on the install page — re-export if it changed); mint an app-level token (connections:write) under Basic Information → App-Level Tokens if the app has none; then set read.mode: socket and the grants you want in channels.slack.publish, restart the-loop channels listen, and check the-loop channels status — its commands: line should read /the-loop over Socket Mode with the families you granted, its mentions: line should name the scope and the event as present, and its shortcuts: line the two shortcuts. /the-loop help in Slack is the end-to-end test for the command; @the-loop help in a channel the bot is in, for the mention. If your channel is a direct message, run the-loop channels status --probe too — it asks Slack what the channel is and which scopes the app was granted, and says so when the two do not match (see Which events your channel needs).

2. Mint the two tokens ​

  • Install the app to the workspace (OAuth & Permissions → Install). The bot token (xoxb-…) is what posts, reads and reacts. Export it under the name channels.slack.botTokenEnv names (default THE_LOOP_SLACK_BOT_TOKEN).
  • Create an app-level token (Basic Information → App-Level Tokens, scope connections:write). The app token (xapp-…) is what the-loop channels listen connects with over Socket Mode. Export it under channels.slack.appTokenEnv (default THE_LOOP_SLACK_APP_TOKEN).

Both can live in the .env file the CLI config names (env.file); neither ever appears in a config, a state file, the event log or channels status output. Invite the bot to the channel it will post in, and copy that conversation's id from its details pane. A public or private channel is C… or G…; the direct message with the bot is D…, and works as a channel in every respect — see Which events your channel needs for what a DM requires of the app.

3. Configure the channel ​

yaml
routing:
  authorizedUsers:                    # identity, declared once per person
    - github: octocat
      slack: U0456GHIJKL                # the member id (U…), never a display name
      name: octocat
channels:
  ledger: github
  slack:
    enabled: true
    channel: C0123ABCDEF
    subscribe: [session.awaiting_input, phase-approval-pending, pr-review-pending,
                comment.agent, work-item-complete, standing.started]
    publish: [work-item.reply, gate.feedback, control.command,
              instance.command, standing.command]
    kickoff:
      repo: octocat/hello-world         # where a top-level message becomes an issue,
      labels: ["the-loop: auto-execute"]  # every routing.autoExecuteLabels entry, so the item is armed
    read:
      mode: socket                      # buttons and the slash command need this (gateway behind a gateway)

subscribe is what the channel hears; publish is what a message on it may become — the channel's authority, one grant per kind of act, all off except work-item.reply until you write them down. Every option is in the channels options.

Which events your channel needs ​

Slack emits a different message event per kind of conversation and delivers only the ones your app subscribed to. The kind is in the channel id's first character:

ConversationIdBot scopeBot event
public channelC…channels:historymessage.channels
private channelC… or G…groups:historymessage.groups
direct message with the botD…im:historymessage.im
group direct messageG…mpim:historymessage.mpim
a mention (@the-loop …) in any channel kindanyapp_mentions:readapp_mention

The manifest above carries all five, so an app imported from it works in any of them. The last row is the one every typed message in a channel or a thread now arrives as (issue-389): without it a room, a thread and the central channel hear nothing typed, while a DM (message.im) and an all room still do. An app created before issue-362 carries only the first two — and a DM configured on such an app fails in the quietest way the-loop has: the bot posts, opens threads and answers button presses normally, channels status reports everything green, and nothing anyone types is delivered until the listener next starts or reconciles. Reading a DM is not the problem (the bot token can already do that, which is why the catch-up read recovers everything); being told about one is.

the-loop channels status names the conversation kind from the id alone, and calls out a D… because that is the case a pre-issue-362 app certainly cannot serve:

console
$ the-loop channels status
  channel:      D0AU0SGP30T
  read:         socket, reconciling every 900s
  channel kind: direct message — needs bot scope im:history and bot event message.im
  [!] channel D0AU0SGP30T is a direct message: it needs the bot scope im:history and
      the bot event message.im, which the-loop's app manifest did not always carry …

--probe replaces the guess with the measured answer — one conversations.info on your own channel and one auth.test for the scopes the app was actually granted (no token is ever printed):

console
$ the-loop channels status --probe
  channel kind: direct message — needs bot scope im:history and bot event message.im
  probe:        conversations.info says im; granted bot scopes: chat:write, channels:history, …
  [!] channel D0AU0SGP30T is a direct message and the app lacks the bot scope im:history —
      Slack never delivers message.im, so replies and kickoffs are only read when the
      listener starts or reconciles. …

The listener runs the same probe once when it connects and logs each finding as a warning, so an operator who never runs status still hears about it. Fix it by replacing the manifest and reinstalling.

4. Run it ​

bash
the-loop start              # one process: the control-plane service, the ledger's ingresses,
                            # and — with read.mode: socket — the Slack listener, hosted
the-loop status             # …with a slack-listener row: running (hosted in the service, pid …)
the-loop channels status    # what is configured, which grants hold, whether buttons and
                            # commands can arrive — and, when they cannot, the exact steps

the-loop start reads the config and brings up everything it asks for, the long-lived Slack connection included: with channels.slack.enabled: true and read.mode: socket the service hosts the listener as a thread under its own lifespan (service.hostIngresses, the default), exactly as it hosts the poller and the webhook receiver. the-loop stop takes it down with the service, the-loop restart --with-upgrade brings it back on the new version, and the-loop status shows it as slack-listener running (hosted in the service, pid …). Both tokens must be in the service's environment (the .env file the config names is loaded at start); with one missing, start reports the row as failed and says which. the-loop channels listen remains the foreground form — for hostIngresses: false, or for watching the connection in a terminal — and takes the same single-instance lock, so it refuses to run beside a hosted one. Setting read.mode to anything but socket in the config file stops the hosted listener within a few seconds, no restart needed — the way to take one of two instances off a shared Slack app — and setting it back starts a fresh one with the edited config.

No webhook server, no Request URL. Socket Mode is an outbound WebSocket the-loop opens to Slack with the app-level token; Slack then pushes message events, button presses and slash commands down that connection, the listener acknowledges each on the same socket, and the ephemeral answer to a command is an outbound HTTPS POST to Slack's response_url. Nothing listens for Slack, nothing is exposed, no port is opened — which is why the manifest carries socket_mode_enabled: true and no request_url. (Slack's classic HTTP delivery, which would need a public endpoint, is deliberately not offered.)

One long-lived connection. The listener (hosted or foreground) asks Slack for a one-time wss:// URL (apps.connections.open), connects, and keeps that socket open — a ping every few seconds detects a dead one, and when Slack rotates the connection (it announces a disconnect first) the SDK reconnects on its own. Every envelope — message events, button presses, slash commands — arrives over that one socket and is acknowledged on it. Delivery happens only while a listener is connected: with none running, a /the-loop command fails visibly in Slack and message events are retried briefly, then dropped. Run one listener per instance — Slack load-balances envelopes across an app's open connections, so two would each see half of them.

Several instances, one app: run one gateway per app (issue-496). Two instances cannot each hold a listener on one app — that is the split above. Instead the gateway holds the app's one Socket Mode connection, stores every envelope before acknowledging it, and each instance polls its own queue with a gateway block in its CLI config and no listener of its own (the-loop status reads off · gateway configured for the listener). The gateway reads the same two tokens from its own gateway-config.yaml — slack.socket.appTokenEnv (the app-level token, connections:write) and slack.socket.botTokenEnv — so the manifest and the tokens above are minted once, for the gateway, and never for an instance behind it. The move, step by step: Moving to a gateway.

With read.mode: poll the daemons read thread replies on a background thread instead and listen is not needed — but no button press and no slash command can arrive that way, because Slack delivers both only to a connection that acknowledges within seconds. channels status says which you have.

The modes of interaction ​

You want to…Do this in SlackThe grant it needsWhat happens
see what the loop needs from younothing — subscribe the events— (subscribe)one thread per work item, opened when it starts; every event a reply into it (in a room the work item declared, a message in the room)
follow the work as it movessubscribe phase.started, phase.completed, work-item.closed— (subscribe)every phase's start and end as the loop:<phase> label changes — a human gate's start says it is waiting on you — and the work item's end when its issue is closed or its pull request merged (issue-378)
be heard at all, in a channel or a threadmention it: @the-loop … — every message meant for the-loop carries the mention (issue-389)— (+ read.mode: socket)the mention is what makes a message input; one without it is ignored (not-addressed) — no record, no reaction — except in a DM with the bot, or in a room an authorized user declared --listen all (addressing the-loop)
answer the agent's questionreply @the-loop <your answer> in the work item's threadwork-item.reply (default)mirrored onto the work item as the-loop's own marked comment, delivered into the waiting session
hand the loop a conversation as context@the-loop record-context in the thread, or ⋯ → Add as the-loop contextcontext.added (+ socket)the thread is snapshotted onto the work item as a marked context.added record naming you, and the session appends it to docs/specs/<id>/context.md with its provenance
record a decision the room just made@the-loop record-decision <text>, or ⋯ → Record the-loop decision (a form: text, kind, rationale)decision.recorded (+ socket)a marked decision.recorded record attributed to you; the session writes docs/decisions/decision-<nnn>.md from it. Authorized users only
let a room's stakeholder feed the loop@the-loop add-collaborator @login or slack:U…control.command (+ socket)the issue-307 keyword with a Slack member id: from then on their mentions are input on this work item (context, replies), never a decision or a keyword
approve or reject a phase, a PRreply @the-loop approved / @the-loop changes requested, or press the buttongate.feedbackrecorded on the work item unmarked, as your answer; the gate reads it there
start, stop, pause, resume, execute, cleanup… a work item that has a threadtype @the-loop start (the keyword's last word) in its threadcontrol.commandrecorded on the work item unmarked, the configured keyword composed for you; the ledger's ingress executes it — exactly as if you had typed it on the ticket
sign the phase-selection checklistpress Execute on the checklist message (or type the-loop execute)control.command (+ read.mode: socket)the same unmarked the-loop execute record; the message is edited to say so (buttons)
start a work item you just filed from Slackpress Start on the-loop's "opened …" reply (or type the-loop start)control.command (+ socket)the same unmarked the-loop start record; the message is edited to say so
start a work item that has no thread yet/the-loop start #123control.command (+ read.mode: socket)the same record on the ticket; the start opens the thread
file a new work item from anywhere/the-loop new <repo>: <title>work-item.create (+ socket)the same issue a top-level message opens; its thread is opened in the home channel and you are answered with the link (issue-378)
file a new work itempost @the-loop <repo>: <title> as a top-level message in the channel (the prefix optional)work-item.createan issue is created in the repository the message named (or kickoff.repo) with kickoff.labels, the thread is bound to it and told the link. Named none it knows? the-loop asks, with your declared repositories as options (read.mode: socket)
talk to a standing sessionreply in its threadwork-item.replydelivered into its pane (no ticket, so no mirror — the event log is the trail)
start, stop or restart a standing session/the-loop standing start <name>standing.command (+ socket)the same verb the-loop standing start runs
ask the instance how it is, restart it, upgrade it/the-loop status · /the-loop restart · /the-loop upgradeinstance.command (+ socket)what the-loop status / the-loop restart [--with-upgrade] do
know which thread is whosethe-loop channels threads (from a shell)—the conversation map, ids only

Every accepted message is acknowledged on itself: 👀 the moment it passes the allow-list and the grant, ✅ when the action landed, ⚠️ when it did not (channels.slack.reactions). A message that was dropped — a stranger's, a bot's, a kind the channel is not granted — gets nothing: a refusal leaves no mark. A slash command has no message of yours to react on; its receipt is the ephemeral answer only you see.

Addressing the-loop ​

A conversation reaches the-loop only when it names it (issue-389, decision-133). Every message meant for the-loop carries @the-loop, which Slack delivers as the app_mention event; the same message's message.* copy is not input. The rule holds in every shape — a declared room, a thread the-loop opened in the central channel, a reply under its own question, a kickoff at the top of the channel — with two exceptions: a direct message with the bot, where Slack sends no app_mention and the DM is addressed by construction, and a room an authorized user declared with --listen all. Nothing matches text for the mention, which is why it needs read.mode: socket (limits). The first word after the mention is read as a fixed grammar; anything else is a reply. Every message that opens a room conversation carries a one-line hint naming the mention and help.

In a room, thread or channel, typeIt becomesGrantNeeds
@the-loop record-context (in a thread)context.added: the thread — root and every reply so far, each with the author's name, time and permalink — snapshotted onto the ticket as a marked record naming you, and appended by the session to docs/specs/<id>/context.md with its provenance. Capped at 150 messages / 40,000 characters with the thread's link for the rest; never summarised. A second record-context on the same thread records only what is new, and says so when nothing is. At the top of a channel, the message alonecontext.addedsocket
@the-loop record-decision <text>decision.recorded: a marked record attributed to you carrying the text, the time and the message's permalink (a kind — product, design, tech — and a rationale when the form gave them); then the session writes docs/decisions/decision-<nnn>.md with you as decider. Never read as a gate answer, whatever the text says; empty text is refused with the grammardecision.recordedsocket
@the-loop add-collaborator @login / slack:U… (remove-collaborator likewise)control.command: the issue-307 keyword composed for you, the roster entry carrying whichever ids you gave — a Slack member id is enoughcontrol.commandsocket
@the-loop <keyword> — start, execute, add-channel slack@#room --listen all, … the last word of any configured keywordcontrol.command, as the keyword typed in a thread today; the configured keyword is composed exactly as the slash command composes itcontrol.commandsocket
@the-loop helpan ephemeral answer only you see — the grammar and the grants this channel holds. Nothing recorded—socket
@the-loop <anything else>work-item.reply, mirrored and delivered to the session as todaywork-item.replysocket
any message without the mentionnothing (not-addressed): no record, no reaction, no reply — unless the room is declared --listen all or it is a DM with the bot——
⋯ → Add as the-loop context / Record the-loop decision (the message's menu)exactly the typed mention above, on that message; the decision one through a modal — the text pre-filled from the message, a kind (product / design / tech), an optional rationale. Answered ephemerally with the record's link or the refusalthe samesocket

Every accepted mention gets the reactions above, and every act that writes a record is answered with one reply in the message's thread carrying the record's link. Every act ends in the session: the record is delivered with a preset prompt naming what was recorded, by whom, the record's URL and the file the session is to update; when no session is running the record stands on the ticket and the next session finds it with the-loop channels records <ref>.

Who may do what — two tiers, per act:

TierWhoActs
inputrouting.authorizedUsers and this work item's collaborators (issue-307 — now addable by Slack id, so a room's stakeholder needs no GitHub login)record-context, a reply, help
bindingrouting.authorizedUsers onlyrecord-decision, every control keyword (add-collaborator included), add-channel --listen

A collaborator who tries a binding act is told so, ephemerally, and nothing is recorded; a member on neither list is dropped in silence, as a stranger's message always was. A collaborator's record names them by the ids the roster holds, never by what the message says. A snapshot copies other people's words — scrubbed of tokens, broadcasts, markers and the-loop's own envelopes — onto a ticket and into a repository that may be public; recording context is an act of the person who asked for it.

A room for one work item ​

The central channel holds every work item, one thread each. When one work item has a room of its own — #tmp-issue-378, with the six people who care about it — an authorized user declares it on the ticket or from a shell (issue-375):

text
the-loop add-channel slack@#tmp-issue-378

From then on the room is the conversation (issue-378): the-loop posts one message saying so, and every update — a question, an approval request, a phase starting or ending, a comment mirrored from the ticket, the close — is a message in the channel, not a reply inside a thread nobody opens. Anything a member types in the room with the mention — @the-loop …, top-level or under one of the-loop's messages — is a message on that work item; nothing posted there ever opens a new issue. A declared room hears only mentions by default (issue-389): the rest of the room's talk is the room's, and never reaches the ticket. A mention inside a thread is answered in that thread (issue-507): the agent's next comments on the work item are posted as replies there, until the room hears something that starts a new conversation (a phase starting or ending, an approval request, a comment from the ticket) or a mention somewhere else moves them. A mention at the top level is answered at the top level. Nothing needs declaring for this, and no verb binds a thread by hand. A small, focused room can be switched back to hearing everything:

text
the-loop add-channel slack@#tmp-issue-378 --listen all

--listen mentions is the default; all makes every message an authorized member or collaborator types there input, as a room was before issue-389, and a mention there is simply a message. Only an authorized user may declare a room and only an authorized user may set its mode (a collaborator's attempt is refused); re-declaring replaces the mode, and the-loop channels threads and channels status show it. Subscribe the room's events like any other: phase.started and phase.completed give the room a timeline, work-item.closed gives it an ending. A conversation that already lived in a thread moves to the room when the room is declared, and the thread it left is told where it went.

Reading it on a phone ​

The GitHub comment is the record; the Slack message is the interface — and since issue-338 they are no longer the same text. A message whose text fits in maxChars (default 1500) arrives as written. A longer one arrives as a digest the channel computes from the text's own structure — no model reads it, so every sentence you see is the author's:

  • the ask first — the first question in the text (or the sentence that says reply …), in bold, before any context;
  • a choice as a numbered list — bullets, numbering and GitHub task boxes become 1., 2., … with ☑ / ☐ kept, so a reply can be one number;
  • pointers for what Slack cannot use — a code fence, a table or a stack trace is replaced in place by ⟨code: 12 lines⟩ / ⟨table: 4 rows⟩ / ⟨stack trace: 9 lines⟩, and an absolute path keeps its last two segments (…/channels/slack.py);
  • the rest in order, cut at a sentence — never mid-sentence, then one closing line, … full text: GitHub, linking the comment or the work item.

The phase-selection checklist, with maxChars: 700:

text
*🤖 _the-loop_ — which phases does this work item need?*

Before the loop starts, tell it what this item actually needs. *Untick anything this
work item does not need — right here on this comment — then reply `the-loop
execute`.* The tick state at that moment is frozen and becomes the graph this item
walks.

1. ☑ brainstorming
2. ☑ requirements-definition
3. ☑ design
4. ☑ test-planning
5. ☑ tasks-breakdown
6. ☑ implementation
7. ☑ verification

*Every phase of this loop is selectable — including the reviews, the security
review and the approval gate.*

_… full text: GitHub_
[Open on GitHub] [Execute]

The phone's notification preview (Slack's fallback text) leads with the same ask. maxChars is the lever: turn it down and the digest fits the smaller screen; turn it up to Slack's 3000 and most comments arrive whole. To have the old behaviour back — the first maxChars characters and a note — set longMessages: truncate. Whatever the length, GitHub markdown is drawn as Slack mrkdwn (**bold**, headings, links, task boxes) and the-loop's own <!-- … --> markers no longer show.

Images and voice notes ​

A screenshot attached to a reply reaches the session as a file it can open; a voice note reaches it as Slack's own transcript (issue-416, decision-135). the-loop looks inside neither: it downloads the file with the bot token to <state.root>/local/attachments/<work item>/, pastes the path into the pane, and the session — Claude Code — reads an image or a PDF with its own file-reading tool. For an audio clip it forwards the transcript Slack produced before the message was delivered (the captions, stripped to text; the preview when the captions cannot be read), re-reading the file for a few seconds while Slack is still transcribing. There is no speech-to-text, OCR or model call on a file anywhere in the loop, by the owner's rule.

What the two readers see:

The ticket (the record)The session (the pane)
a screenshot📎 shot.png (image/png, 120 KB) — <permalink>1. image · shot.png (image/png, 120 KB) — saved at <path> — <permalink>, under a frame that says to read it with the file-reading tool and that everything is UNTRUSTED data
a voice notethe same 📎 line, then > Slack's transcript: …the same line, then Slack's transcript: "…"
anything elsethe 📎 linethe line, with the kind (video, pdf, file) and the path

A message that is only a screenshot is delivered — the Attachments section is its text — where before it was refused as empty. A gate answer or a control keyword with a file attached names the file on its record the same way, since the ledger's ingress is what delivers those. A thread recorded with record-context names each message's files as links, and a kickoff's issue body ends with the same 📎 lines; neither fetches anything.

The app needs the files:read bot scope, which the manifest above declares (read-only: it lets the bot download a file it can already see). An app created before issue-416 lacks it — re-import the manifest and reinstall (upgrading an existing app); until then a file is named and linked for the session, never fetched, and the-loop channels status --probe says so. The limits are fixed: ten files per message, 25 MiB per file, 30 s per download; a file over them is named with the reason and the words still arrive. Fetched files stay under local/ until you delete them (the state page).

Starting a work item from Slack ​

Two gestures, depending on whether the work item exists.

It exists on GitHub and is labelled (every auto-execute label, routing.autoExecuteLabels) but has no thread yet. Run /the-loop start #123 — or owner/repo#123, github:owner/repo#123, or paste the issue's URL. the-loop records the-loop start on the issue under your own GitHub identity (the envelope names you) and the ledger's ingress executes it on its next delivery or poll cycle, exactly as a typed comment; the accepted start then opens the work item's thread here (issue-317), and everything after that is a reply into it. #123 resolves against kickoff.repo; a work item may name only a repository this instance is configured for (the top-level repositories) or a work item it already manages — anything else is refused, and nothing is written.

It does not exist yet, and you are anywhere in Slack. Run /the-loop new <repo>: <title> — more lines are the body, create is an alias (issue-378). With the work-item.create grant the-loop opens the issue exactly as a top-level message would (the same prefix rule below, kickoff.repo as the fallback, kickoff.labels), opens the work item's thread in the home channel with the link and the Do / Start buttons, and answers you — only you — with the link and where the thread is. A repository it cannot resolve is refused with the candidates named and nothing is created: a slash command has no message to hang a picker on, so the <repo>: prefix is the answer.

It does not exist yet, and you are in the channel. Post a top-level message in the channel — the first line is the title, the rest the body. With the work-item.create grant, the-loop opens the issue (labelled from kickoff.labels, so list every auto-execute label there to arm it), binds the message's thread to it and replies with the link. Then choose how it runs (issue-529): press Do for a tactical task with no spec chain or phase gates (the ad-hoc loop), or Start for the full PDLC. Each button carries one line saying what it runs. You can also type the-loop do or the-loop start in that thread (with control.command), or run /the-loop do #<n> or /the-loop start #<n>. Any of these records your choice on the new issue.

Which repository? The message's, if it says so (issue-341). Start the first line with a repository prefix and that is where the issue goes:

slim-gym: flaky teardown in the batch runner

A bare name, an owner/repo or a host/owner/repo all work, and the prefix is stripped from the title. It is resolved only against the repositories you declared — the top-level repositories — so it is never guessed. With no prefix, kickoff.repo takes it. A first line that merely happens to carry a colon (fix: flaky teardown) is not a prefix and goes to kickoff.repo as it always has.

And if the message names none it knows, it asks (issue-349). You do not have to know the prefix to file your first issue. Post the message; the-loop replies in its thread with your declared repositories as buttons (five or fewer) or a select menu (more), and the one you pick is where the issue is opened — from exactly the text you wrote. Nothing is created until you pick, and the reply tells you about the <repo>: prefix so the next one skips the question:

text
Which repository should this go in?
Nothing is created until you pick. Next time you can skip this by starting your
message with `<repo>: `.

[ expertise-help/slim-gym ]  [ jchou2/devbox ]  [ octo/app ]

A prefix that matched several repositories narrows the question to those; one that matched none offers the whole list. Three things it will not do: ask where it cannot receive a press — read.mode: socket is required, exactly as it is for every other button, and in poll mode the refusal you already know is unchanged (channels status says which you have); ask when you have declared no repositories; and ask when your message is nothing but a prefix, because no pick puts words in an empty message. The question is held for a day, answered once — a double tap opens one issue — and only by the member who posted the message. A tap that cannot be honoured (the question expired, somebody already answered it, the grant was taken away) says so on the question itself and leaves the options in place, so you never tap into silence.

Once the loop runs, the thread carries its questions and approvals: answer the phase-selection checklist by ticking it on the ticket and pressing Execute on the checklist message — or typing the-loop execute in the thread — the execute is relayed and signs whatever is ticked there (a checklist written inside the Slack message is quoted in the record and not read; see limits).

The buttons ​

Five buttons, on the messages that would otherwise ask you to type something back — with one exception, the repository picker, which answers a question the-loop asked rather than standing in for a keyword. Each is rendered only where a press can be received and acted on (issue-309, issue-337):

ButtonOn which messageIt stands forRendered when
Approve / Request changesan approval request (phase-approval-pending, pr-review-pending, security-sign-off-pending)the reply approved / changes requestedread.mode: socket + gate.feedback
Executethe phase-selection checklist, mirrored from the ticket (comment.agent)the keyword the-loop execute (your configured routing.control.keywords.execute)read.mode: socket + control.command
Do / Startthe-loop's "opened #N — this thread is now the conversation" reply to a kickoff, each with one line saying what it runs (issue-529)the keyword the-loop do (the ad-hoc loop) / the-loop start (the full PDLC)read.mode: socket + control.command; a disabled keyword renders no button
A repositorythe-loop's "which repository should this go in?" reply to a kickoff that named none it knows (issue-349)not a typed reply — the argument to the work item the question is holdingread.mode: socket + work-item.create

A press is exactly the typed reply: the button's value enters the same pipeline a message does — your member id against routing.authorizedUsers, the classification, the grant, the unmarked record on the ticket — so a button adds no authority the grant did not already give, and an unlisted member's press does nothing, silently. Execute and Start are one tap where a phone keyboard used to be the slowest part of the loop.

The outcome is written onto the message you pressed. Once the press is processed the message is edited: the pressed buttons are replaced by a line saying who pressed what and what happened — ✅ Execute — pressed by @you · recorded on o/r#337 (link) — the loop runs it on its next ingress — and the Open on GitHub button stays. A press that did not land (the ledger refused the comment, no session could take the reply) keeps its buttons beside a ⚠️ line carrying the error, so you can press again once it is fixed. The 👀 / ✅ / ⚠️ reactions stay too — they are the acknowledgment; the line is the outcome.

The app-level token is required, and here is why. Slack delivers a button press to exactly two places: a public Request URL it can POST to, or a Socket Mode connection that acknowledges the press within three seconds. the-loop exposes no endpoint (decision-084), so the only receiver is the Socket Mode listener — which connects with an app-level token (xapp-…, scope connections:write, minted under Basic Information → App-Level Tokens). A 60-second poller has nowhere to receive the click, which is why no button is rendered in poll mode: a button nobody can receive is worse than none. the-loop channels status prints, under its buttons: line, only the steps your configuration still needs:

text
  buttons:      Approve / Request changes: off · Execute / Do / Start: off — a press reaches the-loop only over a Socket Mode listener connected with the app-level token. Still needed:
                  1. mint an app-level token: api.slack.com/apps → your app → Basic Information → App-Level Tokens → Generate (scope connections:write), and export it as THE_LOOP_SLACK_APP_TOKEN (now: unset)
                  2. set channels.slack.read.mode: socket (now: poll)
                  3. add to channels.slack.publish: gate.feedback (Approve / Request changes), control.command (Execute / Do / Start)
                  4. the-loop restart — the service hosts the listener; `the-loop status` shows the slack-listener row, and this line reads on

Standing sessions from Slack ​

A standing session owns no work item, so Slack is one of its two surfaces. When it starts it opens a thread (standing.started); a reply there reaches its pane. To bring one up or take it down from Slack:

text
/the-loop standing list
/the-loop standing start supervisor
/the-loop standing stop supervisor
/the-loop standing restart supervisor

These run the-loop standing <verb> on the host the listener runs on — the same core verb the CLI, the dashboard and POST /api/v1/standing-sessions/<verb> call — and answer with the same outcome rows. They need the standing.command grant. Creating and deleting a standing session stay the dashboard's and the API's (decision-100).

The control plane from Slack ​

What the control plane offers from Slack, and what it does not:

OperationFrom SlackElsewhere
liveness of the service, the daemons, the standing sessions; which instance this is/the-loop statusthe-loop status, GET /api/v1/status, the dashboard
restart every service/the-loop restartthe-loop restart, POST /api/v1/restart
upgrade the loop — upgrade the CLI and restart/the-loop upgradethe-loop restart --with-upgrade, POST /api/v1/restart {withUpgrade: true}
a work item's session: start, pause, resume, stop, cleanup/the-loop <verb> <work-item> (via the ledger)the-loop sessions <verb>, the ticket, the API
standing sessions: list, start, stop, restart/the-loop standing …the-loop standing …, the API, the dashboard
create / delete a standing session, read a transcript, the event stream, config edits—the dashboard, the API, the MCP tools

restart and upgrade schedule a detached the-loop restart — the answer names the pid and the logfile — and /the-loop status tells you when it is back. The listener itself (channels listen) is not one of the restarted services, so the command that restarted the instance keeps answering. These need the instance.command grant, which is separate from control.command on purpose: letting Slack start work items is not the same decision as letting it restart the daemon (decision-116).

The slash command, in full ​

text
/the-loop help
/the-loop <keyword> <work-item> [@login] [instance:<name>]
/the-loop new [<repo>:] <title>
/the-loop status | restart | upgrade
/the-loop standing list
/the-loop standing start|stop|restart <name>
  • new [<repo>:] <title> (create is an alias) opens a work item (issue-378): the text is read exactly as a top-level kickoff message is — first line the title with an optional <repo>: prefix, the rest the body — and needs work-item.create. The thread is opened in the home channel and the answer carries the link.

  • <keyword> is any configured control keyword's last word — start, stop, pause, resume, execute, contribute, do, review, cleanup, add-collaborator @login, remove-collaborator @login — or the command's own name when an operator renamed the keyword (routing.control.keywords). A disabled keyword ("") is refused.

  • <work-item> is github:[host/]owner/repo#N, [host/]owner/repo#N, #N (or N) against kickoff.repo, or the issue's / pull request's URL on this instance's own GitHub host. A bare owner/repo is on the host the-loop resolves (integrations.github.host).

  • instance:<name> addresses one instance of several, as on the ticket; two addresses are refused.

  • The answer is ephemeral — only you see it — and says what happened: recorded on <ref> with the comment's link (the loop acts on its next ingress), the status lines, the scheduled restart, the standing rows, or why it was refused. An unlisted member gets no answer at all.

  • Who may use it: the slack ids of routing.authorizedUsers — the same people who may type a keyword on the ticket. Each verb family needs its grant in channels.slack.publish: control.command, work-item.create, instance.command, standing.command. /the-loop help lists which this channel holds.

Through the ledger, never around it ​

A gate answer or a control keyword from Slack is never acted on by the Slack code. It is written onto the work item as an unmarked comment under the operator's own credential, with the keyword intact and an invisible envelope naming the Slack member and their GitHub login — so the ledger's ingress (the webhook receiver or the poller) reads it as that person's comment and runs every guard a typed comment meets: the self-authored marker check, routing.authorizedUsers, the control seam's named-actor re-check, the instance's scope. What GitHub shows is "control command from octocat (slack:U…) on the slack channel" followed by the quoted keyword. The work item stays the single record; an audit never needs Slack. This is also why a relayed keyword acts on the ledger's next ingress — a webhook delivery, or one poll interval — rather than instantly.

Limits ​

  • A mention needs read.mode: socket. app_mention is an event, and nothing matches message text for <@the-loop>; so in poll mode the-loop hears no channel or thread message — a DM's, and an all room's, excepted — and channels status says that the mention rule needs Socket Mode. The shortcuts and the modal are socket-only by construction, as every interactive payload is.
  • Socket Mode is required for buttons and the slash command (read.mode: socket), and so is the app-level token it connects with — Slack delivers a press or a command only to an acknowledging connection or a public Request URL, and the-loop exposes none (the buttons); in poll mode a DM's and an all room's replies still work, on the daemons' interval, and no button is rendered. the-loop channels status lists the steps that remain.
  • The listener needs the service's environment to carry both tokens. the-loop start hosts it only with read.mode: socket and both tokens set; the-loop status shows the row, and start reports failed naming the missing variable. With service.hostIngresses: false nothing hosts it — run the-loop channels listen in the foreground under your own supervisor.
  • A slash command cannot bind to the thread it was typed in — Slack's payload carries no thread — so the work item is always an argument.
  • A checklist inside a Slack the-loop execute is not read. The record quotes your message, and the phase-selection gate reads only unquoted checklist lines; tick the checklist on the ticket and type the keyword in the thread.
  • Private channels need the groups:history scope and the message.groups event — both in the manifest above; an app created before issue-334 needs them added (upgrading an existing app).
  • No completion receipt for a relayed keyword beyond the ✅ on the record: the thread that opens when a start is accepted, and the events you subscribe to, are the feedback.
  • A command, a button press, a shortcut or a mention issued while no listener was connected is lost — the first three visibly, to the member; a mention silently, once Slack's own retries run out. The member issues it again. A DM's and an all room's replies and kickoffs are caught up; see Downtime.
  • A file is fetched, never read. the-loop forwards a screenshot as a path and a voice note as Slack's transcript; it runs no speech-to-text, OCR or model on either, so what the session makes of an image is the session's, and what Slack heard is what arrives (images and voice notes). Nothing removes the fetched files.
  • The digest is structural, not a summary. It leads with the first question the text asks, so a comment whose decision sits after a rhetorical question early on leads with the wrong one; the closing link is the remedy, and conclusion-first writing the prevention. A code block you wanted inline is a pointer — longMessages: truncate, or a larger maxChars, brings it back (reading it on a phone).

Downtime ​

What happens to what members did while the-loop was stopped, restarting or upgrading:

WhatWhile the-loop is downWhen it is back
a keyword or gate answer already recorded on the ledgerit is a GitHub comment; nothing is lostthe ledger's ingress executes it on its next cycle — the GitHub side reconciles with its own cursors
a reply or kickoff message in a DM or an all room, read.mode: pollstays on Slackthe next poll cycle reads every bound thread from its saved cursor and processes what accumulated, once (a mention-gated conversation is skipped, its cursor untouched)
a reply or kickoff message in a DM or an all room, read.mode: socketSlack retries the undelivered event a few times over a few minutes; beyond that it stays on Slackthe listener runs a catch-up read over every bound thread and the kickoff cursor the moment it connects (channel.caught_up), and again every read.catchUpSeconds (default 900) while it runs, so what accumulated is processed once; a retry Slack then delivers of a message the catch-up already handled is dropped as duplicate
a mention (@the-loop …) in a room, a thread or the central channelSlack retries the app_mention event a few times over a few minutes; beyond that it is lostnot caught up — the catch-up read skips mention-gated conversations and moves no cursor, because catching a mention up would mean matching text for it, which issue-389 rules out. The member mentions again; a record already written (a context.added, a decision.recorded) is on the ticket and survives
a /the-loop command or a button pressfails visibly to the member in Slack — no listener was connected to take itthe member issues it again; nothing was half-done

There is no dead-letter queue, on purpose. A message the-loop read but could not act on — the ledger refused the record, no session could take the delivery, the kickoff issue could not be created — is recorded (channel.dropped with reason: undeliverable, channel.mirror_failed, bus.record_failed, create-failed; ids and the error, never text — the-loop events --types 'channel.*' --types 'bus.*' lists them), marked ⚠️ on the member's own message, and not retried: a retried kickoff opens a second issue, a retried gate answer answers twice, a retried keyword starts twice. The cursor advances, and the person who typed it posts again — the safe retry for a message that is an instruction. The two transports share the per-thread cursors in the channel state, so the same message is never processed twice whichever path read it. A socket listener reconciles on its own every read.catchUpSeconds (default 15 minutes; 0 turns it off), which is the same read cycle and the ceiling on how late any missed envelope can be — a subscription the app does not carry, a Socket Mode reconnect gap, an acknowledgement that raced a restart. Before issue-362 that cycle ran only at connect, so "missed" meant "until someone restarts the daemon". You may still run the-loop channels poll from cron beside the listener; it is the same cycle again. This is the standard shape for a robust Slack integration, and the one the-loop already uses with GitHub: push for latency, a cursor-based pull for completeness.

When nothing arrives at all ​

The nastiest Slack failure is not an error: it is silence. A member mentions the-loop and gets no reaction; they press a button and nothing happens — and Slack's own client answers their press with {"ok": true}, because acceptance is not delivery. On the-loop's side there is nothing to grep: no channel.dropped, no channel.* record, nothing. The envelope was never offered to this process.

The cause is almost always a second Socket Mode consumer holding the same app-level token. Slack load-balances an app's events across every open connection, so a stale channels listen, an instance in a workspace you stopped using, or a host nobody remembers takes roughly half of everything inbound and does nothing with it.

The listener now finds this by itself (issue-413). At connect and on every read.catchUpSeconds reconcile it posts read.splitCheckBeats nonce heartbeats into the central channel, counts how many reach it, deletes them again, and reports a shortfall as channel.split_suspected (warning) and a line in the-loop status and the-loop channels status. Run the-loop doctor slack to take the same measurement on demand.

It is evidence, not proof — Slack exposes no API that lists an app's connections — so the report reads the last eight checks rather than the latest one. At two beats a real split answers clean one time in four: the incident that filed the issue read 2/3 → 1/3 → 3/3 → 1/3 across four runs of the doctor, minutes apart.

Fencing the other consumer out ​

  1. Stop every other holder you can find. A channels listen in a forgotten tmux pane, a second instance, a colleague's laptop. the-loop status on each host says whether its listener is running.

  2. If the heartbeats are still short, rotate the app-level token. This is the remedy that works when the host cannot be found — and in the reported incident it never was:

    console
    # 1. api.slack.com/apps → your app → Basic Information → App-Level Tokens
    #    → open the existing token → Revoke
    # 2. Generate Token and Scopes → add connections:write → Generate
    # 3. put the new xapp-… value in the env file your instance reads
    $ the-loop restart
    $ the-loop doctor slack

    Revoking is what fences the phantom out; generating a replacement is what lets this instance reconnect. Do both, in that order.

  3. Restarting the healthy instance does not help. The other consumer keeps its own connection, and Slack keeps splitting. This was tried first in the reported incident and changed nothing; the rotation fixed it within a second — heartbeats 3/3 on every run afterwards, and a repository-picker press that had been vanishing landed immediately.

The bot token is a separate credential and does not need rotating for this: a split is about connections, and only the app-level token opens one. Rolling any credential also leaves already-running sessions on the old value — see sessions restart.

Why not Slack Workflow Builder ​

The ticket asked whether Slack workflows can be defined in JSON/YAML and imported. For Workflow Builder the answer is no in the way that matters, and it is not the-loop's mechanism for three reasons (decision-116):

  1. It cannot reach the-loop. A workflow's only outbound step is an HTTP webhook, and the control plane binds loopback and exposes nothing — Socket Mode exists so that nothing has to be exposed.
  2. Its messages are the workflow's, not yours. A workflow that posts into the channel posts as a bot, which the pipeline drops as self-authored — correctly, since the person who filled the form is not recoverable from the message and authorization would have to be invented.
  3. There is nothing to check in. Workflow Builder has no importable definition you can carry between workspaces; Slack's Deno "workflow apps" have one, but run on Slack's infrastructure and again need an endpoint to call.

A slash command is delivered into the connection the listener already holds, carries the invoking member's id for the same allow-list to judge, is discoverable in Slack's own UI, and is declared in the app manifest — the importable YAML that does exist.

Released under the MIT License.