claude-slack-channel-bots 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +3 -1
  2. package/docs/agent-director.md +1 -1
  3. package/docs/bots.md +13 -9
  4. package/docs/channel-modes.md +1 -1
  5. package/docs/configuration.md +5 -15
  6. package/docs/migration.md +41 -26
  7. package/docs/reload.md +5 -3
  8. package/docs/scheduled-prompts.md +6 -8
  9. package/docs/server-settings.md +18 -10
  10. package/docs/troubleshooting.md +9 -6
  11. package/package.json +2 -1
  12. package/skills/debug-slack-channel-bots/SKILL.md +268 -143
  13. package/src/ack-tracker.ts +68 -1
  14. package/src/ad-settings.ts +25 -5
  15. package/src/ad-version-gate.ts +35 -4
  16. package/src/agent-director-errors.ts +2 -0
  17. package/src/agent-director-startup.ts +34 -0
  18. package/src/agent-director-template.ts +8 -29
  19. package/src/bot-system-prompt.ts +53 -0
  20. package/src/codex-config-keys.ts +307 -0
  21. package/src/config.ts +84 -2
  22. package/src/cron-dispatch.ts +36 -198
  23. package/src/cron-log.ts +4 -2
  24. package/src/delivery-ad-scope.ts +15 -0
  25. package/src/delivery-constants.ts +13 -0
  26. package/src/delivery-decision.ts +6 -3
  27. package/src/delivery-engine.ts +514 -0
  28. package/src/delivery-intake.ts +146 -0
  29. package/src/delivery-lifecycle.ts +100 -0
  30. package/src/delivery-notices.ts +179 -0
  31. package/src/delivery-policy.ts +113 -0
  32. package/src/delivery-queue.ts +451 -0
  33. package/src/delivery-receipt.ts +195 -0
  34. package/src/delivery-screen.ts +169 -0
  35. package/src/health-check.ts +26 -47
  36. package/src/interject.ts +10 -88
  37. package/src/jsonl-persistence-check.ts +1 -0
  38. package/src/lib.ts +1 -15
  39. package/src/live-row-sequence.ts +28 -0
  40. package/src/lost-message.ts +1 -87
  41. package/src/outage-state.ts +23 -3
  42. package/src/pending-row.ts +6 -0
  43. package/src/permission-poller.ts +5 -0
  44. package/src/persona-diagnostics.ts +31 -0
  45. package/src/persona-episodes.ts +7 -4
  46. package/src/persona-identity.ts +8 -1
  47. package/src/persona-lifecycle.ts +1 -1
  48. package/src/persona-routing.ts +88 -525
  49. package/src/registry.ts +19 -35
  50. package/src/reload-plan.ts +7 -4
  51. package/src/reload-preview-clauses.ts +5 -0
  52. package/src/reload.ts +8 -0
  53. package/src/restart.ts +11 -19
  54. package/src/server.ts +241 -60
  55. package/src/session-manager.ts +378 -74
  56. package/src/slow-recovery.ts +6 -6
  57. package/src/tag-description.ts +10 -0
  58. package/src/tag-wrapper.ts +100 -0
  59. package/src/test-mcp-drop.ts +32 -0
  60. package/src/test-timer-overrides.ts +134 -0
  61. package/src/unavailable-retry.ts +16 -15
  62. package/stop-hooks/slack-reply-guard.sh +42 -8
  63. package/src/inbound-dedupe.ts +0 -105
package/README.md CHANGED
@@ -47,7 +47,7 @@ A single HTTP MCP server that runs several independent Claude Code bots, called
47
47
  ## Prerequisites
48
48
 
49
49
  - [Bun](https://bun.sh) `>= 1.0.21` (agent-director minimum)
50
- - [Claude Code](https://claude.ai/code) 2.1.296 or later for the workers, installed and authenticated: the minimum agent-director states for its exec-form hooks, and the version the fleet runs. A Claude Code too old for exec-form hooks (older than 2.1.139) has its hooks ignored, so every launch stays `pending`. The `claude` on the workers' `PATH` must be Claude Code's native binary or a wrapper that `exec`s it: a launcher or shim that runs Claude Code as a child process makes agent-director ignore every hook (`ad.hook.ignored` with the reason `pid_mismatch`), so every launch stays `pending`.
50
+ - [Claude Code](https://claude.ai/code) 2.1.280 or later for the workers, installed and authenticated: CSCB's supported minimum and the version the fleet runs. No version pin or channels permission is required. A Claude Code too old for exec-form hooks (older than 2.1.139) has its hooks ignored, so every launch stays `pending`. The `claude` on the workers' `PATH` must be Claude Code's native binary or a wrapper that `exec`s it: a launcher or shim that runs Claude Code as a child process makes agent-director ignore every hook (`ad.hook.ignored` with the reason `pid_mismatch`), so every launch stays `pending`.
51
51
  - [`agent-director`](https://github.com/gabemahoney/agent-director) 0.12.0 or later, **installed system-wide** as a prerequisite, like `git` or `docker`. The npm `agent-director` package CSCB depends on is the client only: it finds the system-installed binary at startup and refuses to start when the binary is missing, too old, or unreachable. This release pins the client to `agent-director@0.12.0` and requires the CLI at 0.12.0 or later, installed on the host together with it, and the two are rolled back together; CSCB changes no agent-director code (see "Switching over to agent-director Phase 1" in [Migration](docs/migration.md#migration)). Every agent on the host, with every long-running agent-director process (`agent-director serve` included), is stopped before either binary change (the Phase 1 install, and the rollback's restore of the previous binary) and started again after it. The startup gate refuses a binary below the client's own minimum (`min_binary_version` in the client's `dist/version-floor.json`) or below 0.12.0, and names the required version (see [Startup errors](docs/troubleshooting.md#startup-errors)). To check the install before you start, run `bun run install-check` (see [Checking your agent-director install](docs/agent-director.md#checking-your-agent-director-install)).
52
52
  - [tmux](https://github.com/tmux/tmux) 3.2 or later, with `remain-on-exit` off. agent-director runs it; CSCB starts no tmux process. agent-director does not depend on tmux's `base-index` or `pane-base-index`, so their values don't matter.
53
53
  - Slack workspace admin access (to create and configure one Slack app per persona)
@@ -111,6 +111,8 @@ Replace the channel ID and paths with your own. [Configuration](docs/configurati
111
111
 
112
112
  Talk to a persona in its channels or, when its `dm.enabled` is `true`, by direct message. When its Claude instance needs a tool approved, an **Allow** / **Deny** prompt is posted to its destination, its `permission_prompts` (see [Permission Relay](docs/bots.md#permission-relay)).
113
113
 
114
+ CSCB durably queues accepted messages and types them into the persona’s terminal. Delivery is ordered and at-least-once: the next item waits for transcript confirmation of the previous one, and an unconfirmed item may appear again. The bot answers with `reply`; see [How a persona receives messages](docs/bots.md#how-a-persona-receives-messages).
115
+
114
116
  ### Changing the configuration
115
117
 
116
118
  On a running server, saving `config.json` changes nothing yet. Within about 5 seconds the server writes a preview of the change to `config.json.pending`. Read it, then confirm it by renaming it:
@@ -1,6 +1,6 @@
1
1
  # agent-director
2
2
 
3
- CSCB requires agent-director CLI 0.12.0 or later and pins its TypeScript client to `agent-director@0.12.0`. Workers require Claude Code 2.1.296 or later. Upgrade the CLI with its released installer before upgrading CSCB; see [Migration](migration.md#migration).
3
+ CSCB requires agent-director CLI 0.12.0 or later and pins its TypeScript client to `agent-director@0.12.0`. Workers require Claude Code 2.1.280 or later. Upgrade the CLI with its released installer before upgrading CSCB; see [Migration](migration.md#migration).
4
4
 
5
5
  CSCB runs every bot through [agent-director](https://github.com/gabemahoney/agent-director), installed system-wide. This page covers checking that install, the `install-cscb` skill that walks you through a failed check, and the agent-director settings CSCB reads.
6
6
 
package/docs/bots.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # What a bot can do
2
2
 
3
- The tools each persona's Claude instance gets, how messages reach it, how to inject a message, how permission prompts reach Slack, and the Slack Reply Guard. Scheduled prompts have their own page, [Scheduled Prompts](scheduled-prompts.md).
3
+ Tools, receiving messages, injecting a message, permission prompts and the Slack Reply Guard. See also [Scheduled Prompts](scheduled-prompts.md).
4
4
 
5
5
  ## Tools
6
6
 
@@ -32,9 +32,9 @@ On a host whose Claude Code permission settings don't allow the server's tools a
32
32
 
33
33
  ## Interject
34
34
 
35
- POST to `/interject` to inject a message into a persona's running Claude instance from localhost. The message reaches only the named persona's instance, never any other persona. Only requests from `127.0.0.1` or `::1` are accepted — external callers are rejected with 403. The bot sees it as described in [How a persona receives messages](#how-a-persona-receives-messages); the Slack Reply Guard does not remind it to reply.
35
+ POST to `/interject` to store a message for a configured persona from localhost. The message is queued only for the named persona, including while it is down. Each request is an independent queued item, even with identical sender and text; requests are never merged or deduplicated. Only requests from `127.0.0.1` or `::1` are accepted — external callers are rejected with 403. The bot sees it as described in [How a persona receives messages](#how-a-persona-receives-messages); the Slack Reply Guard does not remind it to reply.
36
36
 
37
- An injected message carries no Slack conversation. If you want the bot to post a reply in Slack, say in the message text where to post it.
37
+ An injected message carries no Slack conversation; say in its text where to post any Slack reply.
38
38
 
39
39
  ### Request
40
40
 
@@ -52,7 +52,7 @@ curl -X POST http://localhost:<port>/interject \
52
52
 
53
53
  ### Response
54
54
 
55
- On success, returns HTTP 200:
55
+ After durable storage, returns HTTP 200. This acknowledges storage, not delivery:
56
56
 
57
57
  ```json
58
58
  { "ok": true, "persona": "planner" }
@@ -69,7 +69,7 @@ On success, returns HTTP 200:
69
69
  | 404 | No persona in the applied configuration has that name or key. |
70
70
  | 405 | Must use POST method. |
71
71
  | 413 | Request body exceeds 32KB. |
72
- | 503 | The persona is configured but not up (broken or retrying), even if its instance is running, or it has no live connected session. Nothing is delivered. The persona's lines in the server log give the cause. |
72
+ | 500 | The message could not be stored (`Message not stored`). |
73
73
 
74
74
  ### Example: crontab reminder
75
75
 
@@ -116,13 +116,15 @@ The template also pre-allows each bot to read its own persistent-memory director
116
116
 
117
117
  ## How a persona receives messages
118
118
 
119
- Every message reaches a persona's Claude instance as its text wrapped in a `<channel source="slack-channel-router" …>` tag. A message from Slack carries a `via` attribute that says how it reached the persona. An injected message (a scheduled prompt or an `/interject` message) carries no `via`. Which channels a persona serves, and at which delivery, depends on the channel mode (see [Channel modes](channel-modes.md#channel-modes)).
119
+ CSCB stores accepted messages, then types their text into the bot’s terminal in a `<channel source="slack-channel-router" …>` tag. Every Slack message carries its sender’s ID; use the shared prompt file to say whose instructions to follow. The bot answers through `reply`. A message from Slack carries a `via` attribute that says how it reached the persona. An injected message (a scheduled prompt or an `/interject` message) carries no `via`. A received item is durably stored; delivered means its complete text is confirmed in the bot’s transcript. Delivery is at-least-once: an unconfirmed item is offered again, so the bot may see it more than once. Each persona uses strict FIFO across Slack, `/interject` and scheduled prompts, with one item in flight; the next waits until the previous item is confirmed delivered. There is no age cutoff. Messages wait while MCP is disconnected, trigger the configured reconnect, and are delivered once connected again. Which channels a persona serves, and at which delivery, depends on the channel mode (see [Channel modes](channel-modes.md#channel-modes)).
120
120
 
121
121
  A message from Slack carries these tag attributes:
122
122
 
123
123
  | Attribute | Value |
124
124
  |---|---|
125
125
  | `chat_id` | The channel ID, or the DM conversation ID (`D…`) for a direct message. |
126
+ | `channel_name` | Channel name without `#`; `dm` for a DM; channel ID if the name cannot be resolved. |
127
+ | `part` | `k/n` for part k of n when a long message is split. |
126
128
  | `message_id`, `ts` | The message's Slack timestamp, for example `1789936743.069939`. |
127
129
  | `user` | The author's Slack display name, falling back to real name, then Slack username, then user ID. For a post by a bot or integration without a user: the post's username, else its bot profile name, else its bot ID. |
128
130
  | `user_id` | The author's Slack user ID. |
@@ -140,7 +142,7 @@ Each way a message reaches a persona:
140
142
  | `@here` / `@channel` broadcast | A message that uses `@here` or `@channel`. In declarative mode: in a channel listed in the persona's `channels`, with either `delivery`. In fungible mode: in a public or private channel its app is in that is not externally shared, at either channel delivery. `@everyone` and user-group mentions are not broadcasts. | Slack attributes | `broadcast` | Yes, broadcast wording |
141
143
  | Every message, shared channel | A message that neither @mentions the persona nor broadcasts. In declarative mode: in a channel listed in its `channels` with `delivery: all`, when at least one other persona in the applied configuration also has `delivery: all` for that channel, whether or not that persona is up. In fungible mode: in a public or private channel its app is in that is not externally shared, where its channel delivery is `all` (a stored choice, after the loop guard; see [Channel delivery in fungible mode](channel-modes.md#channel-delivery-in-fungible-mode)), when at least one other persona in the applied configuration has a channel delivery of `all` for that channel by the same rule, whether or not that persona's app remains in the channel. | Slack attributes | `receive_all_shared` | Yes, shared-channel wording |
142
144
  | Every message, this persona alone | A message that neither @mentions the persona nor broadcasts. In declarative mode: in a channel listed in its `channels` with `delivery: all`, when no other persona in the applied configuration has `delivery: all` for that channel. In fungible mode: in a public or private channel its app is in that is not externally shared, where its channel delivery is `all`, when no other persona in the applied configuration has a channel delivery of `all` for that channel. | Slack attributes | `receive_all` | Yes, only-you wording |
143
- | Scheduled prompt | A crontable line naming the persona fires, and the scheduler posts it to `/interject` for that persona only (see [Scheduled Prompts](scheduled-prompts.md#scheduled-prompts-cscb_cron)). | Only `user` = `cscb-cron:<prompt file name without its extension>` (`cscb-cron:standup` for `standup.md`) and `ts` = the server clock in seconds. No `chat_id`, `message_id` or `via`. | none | Never |
145
+ | Scheduled prompt | A crontable line naming the persona fires, and the scheduler queues it for that persona only (see [Scheduled Prompts](scheduled-prompts.md#scheduled-prompts-cscb_cron)). | Only `user` = `cscb-cron:<prompt file name without its extension>` (`cscb-cron:standup` for `standup.md`) and `ts` = the fire time in seconds, with six decimals. No `chat_id`, `message_id` or `via`. | none | Never |
144
146
  | `/interject` message | A localhost POST to `/interject` addressed by `persona` (see [Interject](#interject)); it reaches only that persona. | Only `user` = the request's `sender` (default `interject`) and `ts`, as for a scheduled prompt. No `chat_id`, `message_id` or `via`. | none | Never |
145
147
 
146
148
  A "Yes" in the Reminder column means the [Slack Reply Guard](#slack-reply-guard-stop-hook) reminds the persona once if it ends its turn without replying, wherever the reminder is on for that persona. Each `via` has its own reminder wording, stating how the message arrived; that section lists them.
@@ -150,7 +152,7 @@ A "Yes" in the Reminder column means the [Slack Reply Guard](#slack-reply-guard-
150
152
  - **Every author counts.** Posts by people, bots, integrations and other personas all arrive by these rules. Each persona posts as its own Slack app, so another persona's post reaches it like anyone else's.
151
153
  - **Anyone in the workspace can reach a persona,** in both modes: in declarative mode in its listed channels, by each channel's `delivery`; in fungible mode in every channel its app is invited to that it serves, by its channel delivery there; and in both modes by DM when its `dm.enabled` is `true`. There is no approval step. In fungible mode an invite changes who that is (see [Who can reach a persona in fungible mode](channel-modes.md#who-can-reach-a-persona-in-fungible-mode)).
152
154
  - **A persona never receives its own posts.**
153
- - **Each message arrives at most once per persona,** even though Slack sends a channel @mention twice. A Slack redelivery more than 10 minutes after the first, or after a server restart, arrives again.
155
+ - **Slack redeliveries add no second queue item, including across server restarts.** Each accepted Slack message keeps its key for at least 24 hours after intake; the paired channel @mention adds no second item.
154
156
  - **In fungible mode, an @mention is delivered from its `message` event.** Of the two events Slack sends for a channel @mention, the `app_mention` event decides nothing and the `message` event carrying the same mention decides, so each @mention is delivered once, whichever event arrives first. An edit that adds a mention to an earlier message sends no new `message` event, so in fungible mode it is not delivered.
155
157
  - **A message without `via` is an injected prompt.** Its `user` label is free-form, so an `/interject` sender label, even one starting with `cscb-cron:`, cannot make it look like a Slack message.
156
158
  - **When the reminder is off.** A persona gets no reminder when its effective `stop_hook_bootstrap` was `false` at its last launch, when it has no `claude_config_dir` (its own or the top-level one), when that directory resolves to `~/.claude`, or when `jq` is not installed. See [Slack Reply Guard](#slack-reply-guard-stop-hook).
@@ -176,6 +178,8 @@ This turn started from a Slack direct message and you haven't replied. If you me
176
178
 
177
179
  When several Slack messages started the turn, the last one picks the wording. A `reply` call after the earliest of them counts as a reply. The persona then continues once: it can reply, or end the turn without replying. That continuation carries `stop_hook_active=true`, which the guard always lets through, so the reminder appears at most once per turn and never loops.
178
180
 
181
+ CSCB types the tag, including when a message arrives mid-turn or is pasted into the composer. The guard recognises these forms in the transcript.
182
+
179
183
  **Only messages from Slack trigger it.** The guard recognises a message from Slack by its `via` attribute; any `via` value counts. Injected prompts (scheduled prompts and `/interject` messages) carry no `via`, so they never trigger the reminder: no Slack conversation is waiting on them. An injected message that arrives right after a Slack message does not cancel the reminder for the Slack message. [How a persona receives messages](#how-a-persona-receives-messages) lists every kind of message and its `via`.
180
184
 
181
185
  ### Turning it on or off
@@ -190,4 +194,4 @@ The guard only recognises a reply made with `mcp__slack-channel-router__reply`.
190
194
 
191
195
  ### When it stays silent
192
196
 
193
- The guard is fail-open: when anything is missing or goes wrong, the turn ends normally with no reminder. Besides the opt-out, it stays silent for a persona with no `claude_config_dir`, one whose directory resolves to your own `~/.claude` (the server never edits `~/.claude/settings.json`), any persona while `jq` is not installed (installing it later is enough), and when the message's `<channel>` tag, which Claude Code writes, isn't in the shape the guard expects. After every CSCB or Claude Code upgrade, check the guard end to end with the steps in the debugging skill's ["Checking the guard after an upgrade"](../skills/debug-slack-channel-bots/SKILL.md#checking-the-guard-after-an-upgrade). How the server installs the hook and the per-persona record it reads are in ["How the guard is installed and what it reads"](../skills/debug-slack-channel-bots/SKILL.md#how-the-guard-is-installed-and-what-it-reads).
197
+ The guard is fail-open: when anything is missing or goes wrong, the turn ends normally with no reminder. Besides the opt-out, it stays silent for a persona with no `claude_config_dir`, one whose directory resolves to your own `~/.claude` (the server never edits `~/.claude/settings.json`), any persona while `jq` is not installed (installing it later is enough), and when the message's `<channel>` tag, which CSCB types, isn't in the shape the guard expects. After every CSCB or Claude Code upgrade, check the guard end to end with the steps in the debugging skill's ["Checking the guard after an upgrade"](../skills/debug-slack-channel-bots/SKILL.md#checking-the-guard-after-an-upgrade). How the server installs the hook and the per-persona record it reads are in ["How the guard is installed and what it reads"](../skills/debug-slack-channel-bots/SKILL.md#how-the-guard-is-installed-and-what-it-reads).
@@ -68,7 +68,7 @@ In fungible mode, a persona's channel delivery for a channel sets which messages
68
68
 
69
69
  ## Permission prompts in fungible mode
70
70
 
71
- In fungible mode, a persona's destination is its `invited.permission_prompts`, `"dm"` when it is absent. Its permission prompts and its server notices, such as lost-message notices and restart-limit warnings, are posted there as the persona. See the `invited.permission_prompts` row in [Persona fields](configuration.md#persona-fields).
71
+ In fungible mode, a persona's destination is its `invited.permission_prompts`, `"dm"` when it is absent. Its permission prompts and its server notices, such as *Message not saved* notices and restart-limit warnings, are posted there as the persona. See the `invited.permission_prompts` row in [Persona fields](configuration.md#persona-fields).
72
72
 
73
73
  - **A channel ID:** any channel; it needs no listing and no DMs. Invite the persona's app to it, or Slack refuses the posts (see "A permission prompt or notice doesn't arrive" in [Troubleshooting](troubleshooting.md#troubleshooting)). Everyone in that channel sees the prompts and notices.
74
74
  - **`"dm"`, written or by default:** a DM from the persona's app to its `dm.contact`. It needs `dm.enabled: true` and a `dm.contact`.
@@ -31,19 +31,14 @@ A complete `config.json` in declarative mode (`allow_invited_channels` absent),
31
31
  "name": "planner",
32
32
  "credentials_file": "~/.config/claude-slack-channel-bots/planner-credentials.json",
33
33
  "working_directory": "~/projects/alpha",
34
- "channels": [
35
- { "id": "C0123456789", "delivery": "all" },
36
- { "id": "C0555555555", "delivery": "all" }
37
- ],
34
+ "channels": [{ "id": "C0123456789", "delivery": "all" }, { "id": "C0555555555", "delivery": "all" }],
38
35
  "permission_prompts": "C0123456789"
39
36
  },
40
37
  {
41
38
  "name": "reviewer",
42
39
  "credentials_file": "~/.config/claude-slack-channel-bots/reviewer-credentials.json",
43
40
  "working_directory": "~/projects/beta",
44
- "channels": [
45
- { "id": "C0555555555", "delivery": "mentions" }
46
- ],
41
+ "channels": [{ "id": "C0555555555", "delivery": "mentions" }],
47
42
  "dm": { "enabled": true, "contact": "U0123456789" },
48
43
  "permission_prompts": "dm",
49
44
  "claude_config_dir": "~/.claude-reviewer",
@@ -61,8 +56,7 @@ A complete `config.json` in declarative mode (`allow_invited_channels` absent),
61
56
  "session_restart_delay": 60,
62
57
  "claude_config_dir": "~/.claude-bots",
63
58
  "ack_reaction": "eyes",
64
- "reply_chunk_limit": 3000,
65
- "reply_chunk_mode": "newline"
59
+ "reply_chunk_limit": 3000, "reply_chunk_mode": "newline"
66
60
  }
67
61
  ```
68
62
 
@@ -144,7 +138,6 @@ Each persona's Slack tokens live in its own credentials file, and `config.json`
144
138
  Write the file with `claude-slack-channel-bots credentials <persona>` in your own terminal, once the persona is declared in `config.json`: the [credentials command](../skills/setup-slack-channel-bots/SKILL.md#credentials-command) of the setup wizard, `setup-slack-channel-bots`. It checks both tokens with Slack and writes the file, mode 0600, only when both pass; see [`claude-slack-channel-bots credentials`](cli.md#claude-slack-channel-bots-credentials).
145
139
 
146
140
  To rotate a persona's tokens, run the credentials command for it again (see the wizard's [Rotate a persona's tokens](../skills/setup-slack-channel-bots/SKILL.md#rotate-a-personas-tokens)), then confirm the pending change (see [Confirming a change](reload.md#confirming-a-change)). Only that persona reconnects, and no restart is needed.
147
-
148
141
  Never put a token in `config.json`, a ticket or a chat.
149
142
 
150
143
  The shipped `slack-app-manifest.yml` also grants the `lists:read` and `lists:write` bot scopes, so a bot can read and write, with its own bot token, a Slack List it has been given access to, such as one list shared by all bots. CSCB itself makes no Lists call. An app created from an earlier manifest must have both scopes added and be re-installed; until then, every Lists call fails with `missing_scope`.
@@ -166,8 +159,6 @@ Any number of personas may list the same channel, each with its own `delivery`.
166
159
  - **Off:** DMs to the persona's app are not delivered. The server logs one `persona-dm-dropped` line naming the persona and `dm.enabled`. The persona never opens, reads or posts in a DM.
167
160
  - **Group DMs** (DMs with more than one person) are never delivered to any persona, whatever `dm.enabled` says.
168
161
 
169
- In declarative mode, a persona with no `channels` must have `dm.enabled` set to `true`.
170
-
171
162
  `dm.contact` is the person who receives the persona's prompts and notices when its destination is `"dm"`: in declarative mode when `permission_prompts` is `"dm"`, and in fungible mode when `invited.permission_prompts` is `"dm"` or unset. In either mode a `"dm"` destination needs `dm.enabled: true` and a `dm.contact`. `dm.contact` gates nothing else: it doesn't limit who can DM the persona.
172
163
 
173
164
  Starting a DM with a user (a `reply` to a user ID, or a persona whose destination is `"dm"`) needs the `im:write` bot scope, which the shipped `slack-app-manifest.yml` grants. A `"dm"` destination always opens its DM through Slack before it first posts there, even when the DM already exists. For an app created from an earlier manifest, a `"dm"` persona's prompts and notices are not posted, and a `reply` to a user ID fails with `missing_scope`, until the scope is added and the app is re-installed.
@@ -176,12 +167,12 @@ Receiving DMs and replying in an existing DM (a `D…` ID) work without the scop
176
167
 
177
168
  ### Permission prompts
178
169
 
179
- This section applies in declarative mode, where `permission_prompts` is required; for fungible mode, see [Permission prompts in fungible mode](channel-modes.md#permission-prompts-in-fungible-mode). It is the persona's destination: its permission prompts and its server notices, such as lost-message notices and restart-limit warnings, are posted there as the persona.
170
+ This section applies in declarative mode, where `permission_prompts` is required; for fungible mode, see [Permission prompts in fungible mode](channel-modes.md#permission-prompts-in-fungible-mode). It is the persona's destination: its permission prompts and its server notices, such as *Message not saved* notices and restart-limit warnings, are posted there as the persona.
180
171
 
181
172
  - **A channel ID:** one of the persona's own `channels`. Everyone in that channel sees the prompts and notices.
182
173
  - **`"dm"`:** a DM from the persona's app to its `dm.contact`. It needs `dm.enabled: true` and a `dm.contact`.
183
174
 
184
- Give each persona a destination that no other persona receives every message in, such as its own channel or `"dm"`. Don't give a persona `delivery: all` in another persona's destination channel. A notice is a Slack post like any other: when two personas each receive every message in the other's destination channel and neither persona's instance can take messages, each loses the other's lost-message notices and posts a notice about it, so they keep posting notices about each other until either instance takes messages again (see [Troubleshooting](troubleshooting.md#troubleshooting)).
175
+ Choose a destination with [channel delivery](#channel-delivery) in mind. If two personas each receive every message in the other’s destination and storing fails, the notices stop after one *Message not saved* each per episode. Further failures are counted in `server.log` (see [Troubleshooting](troubleshooting.md#troubleshooting)).
185
176
 
186
177
  [Permission Relay](bots.md#permission-relay) covers how prompts are delivered and answered, and [Troubleshooting](troubleshooting.md#troubleshooting) what happens when Slack refuses a post to the destination.
187
178
 
@@ -194,7 +185,6 @@ Give each persona a destination that no other persona receives every message in,
194
185
  ```sh
195
186
  CLAUDE_CONFIG_DIR=~/.claude-reviewer claude auth login --claudeai
196
187
  ```
197
-
198
188
  Use `--console` instead of `--claudeai` for a Console account. When neither the persona nor the top level sets one, Claude's own default applies.
199
189
 
200
190
  - A directory that doesn't exist yet is fine when its parent exists.
package/docs/migration.md CHANGED
@@ -55,7 +55,7 @@ Do this beforehand, with the old CSCB running and still installed as the global
55
55
  2. **agent-director's version.** Run `agent-director version` in the bot server's launcher environment as the workers' user. Record its version and resolved path. Stage the released 0.12.0 installer and both binaries, and keep the prior install and flags for restoration. A host already on 0.12.0 or later needs no directory migration; verify its `config_dir` and client compatibility before restarting callers.
56
56
  3. **The tmux socket (operator action).** Pin the tmux socket for the bot server's launcher, the host's sweep schedule and the workers. The sweep schedule is whatever runs `agent-director find-missing` on a schedule on your host: a cron entry, a systemd timer or a loop script. Run `tmux display-message -p '#{socket_path}'` from the bot server's launcher, the sweep schedule's environment and a worker's. Confirm that all three print the same path, that it is the pinned path (a `TMUX_TMPDIR` that names a missing path falls back silently to `/tmp`), and that the three share one HOME. A host with no sweep schedule checks the other two, and runs the one step 11 adds in the same environment. Record the result in the switch-over log.
57
57
  4. **tmux.** Confirm tmux 3.2 or later, with `remain-on-exit` off.
58
- 5. **Claude Code.** Run `claude --version` in the bot server's launcher environment (the same user and `PATH` the server launches workers with), and confirm that the workers' Claude Code is 2.1.296 or later: the minimum agent-director states for its exec-form hooks, and the version the fleet runs. On a Claude Code too old for exec-form hooks (older than 2.1.139), each hook prints nothing, agent-director records `ad.hook.ignored` with the reason `no_exec_form`, and every launch stays `pending`. If it is older than 2.1.296, stop here, before anything goes down. In the same environment, run `command -v claude` and confirm that it resolves to Claude Code's native binary or to a wrapper that `exec`s it. A launcher or shim that runs Claude Code as a child process makes agent-director ignore every hook, recording `ad.hook.ignored` with the reason `pid_mismatch`, and every launch stays `pending`; replace such a launcher now.
58
+ 5. **Claude Code.** Run `claude --version` in the bot server's launcher environment (the same user and `PATH` the server launches workers with), and confirm that the workers' Claude Code is 2.1.280 or later: CSCB's supported minimum and the version the fleet runs. On a Claude Code too old for exec-form hooks (older than 2.1.139), each hook prints nothing, agent-director records `ad.hook.ignored` with the reason `no_exec_form`, and every launch stays `pending`. If it is older than 2.1.280, stop here, before anything goes down. In the same environment, run `command -v claude` and confirm that it resolves to Claude Code's native binary or to a wrapper that `exec`s it. A launcher or shim that runs Claude Code as a child process makes agent-director ignore every hook, recording `ad.hook.ignored` with the reason `pid_mismatch`, and every launch stays `pending`; replace such a launcher now.
59
59
  6. **agent-director's timing settings.** Before the AD directory move, read all nine keys of the `[tmux]` table of the existing installation’s `~/.agent-director/config.toml` (an explicit pre-upgrade path exception). If AD is already upgraded, use `~/.config/agent-director/config.toml` instead. A missing file, a missing key or `0` means the default (see [agent-director's timing settings](agent-director.md#agent-directors-timing-settings)). Also read that file’s `[pause] timeout_seconds` (30 s when the file or the key is missing). Record the effective values in the switch-over log. Confirm that the three windows, `pending_grace_seconds`, `stopping_window_seconds` and `starting_session_seconds`, are the values you intend and each is at or above its minimum: `starting_session_seconds` 60, `stopping_window_seconds` 30, and `pending_grace_seconds` the larger of 30 and ⌈(`create_timeout_ms` + `pipe_close_wait_ms`) / 1000⌉ + 20. A `pending_grace_seconds` above 540 s shortens agent-director's SessionStart wait to 540 s. In the same file, read `[defaults] expire_retention_days`: Phase 1 accepts 1 to 106751, and `0` or a missing key gives 31 days. A negative value or one above 106751 makes Phase 1 refuse its whole configuration with `ErrConfigMalformed`, from the install on: every store-backed verb, `serve` and the hooks. Correct such a value now, and record the effective value in the switch-over log.
60
60
  7. **The call timeout.** Always computing from this host's values, confirm that the `agent_director_call_timeout_ms` the persona configuration will carry (staged below; `60000` when it leaves the setting out) exceeds the need: the largest ceiling among the verbs CSCB calls, plus the 15 s margin (15000 ms). Record the computed need in the switch-over log. With Q, A, C, W and E the host's `query_timeout_ms`, `action_timeout_ms`, `create_timeout_ms`, `pipe_close_wait_ms` and `kill_exit_wait_ms`, and B its `sweep_budget_seconds` in ms, the ceilings, in ms, are:
61
61
  - `kill`: the larger of 2Q + 2A + E + 4W and 3Q + 2A + 5W;
@@ -67,8 +67,8 @@ Do this beforehand, with the old CSCB running and still installed as the global
67
67
 
68
68
  `expire` is not among them: CSCB never calls it. [Sizing the agent-director call timeout](agent-director.md#sizing-the-agent-director-call-timeout) works through examples.
69
69
  8. **Leftover sessions.** As the workers' user, on the pinned socket, compare `tmux ls` with `agent-director list`, and record in the switch-over log every `slack_bot_` session that no row names.
70
- 9. **Stage the new release without installing it.** Choose its exact version, record it in the switch-over log and confirm that it is available to install. Write the persona configuration, with `agent_director_call_timeout_ms`, in a separate file, never `config.json`, which the old CSCB reads. Prepare the Slack apps. Nothing is installed over the global package yet, so step 3's `stop --stop-bots` is the old version's own and reads the old, pre-persona `config.json` from the old state directory, `~/.claude/channels/slack`, where the old package keeps it. In the staged file, name each persona's `credentials_file` in that directory too, never under `~/.config/claude-slack-channel-bots` (step 7 says why). Steps 1, 2, 3, 5 and 6 of [Upgrading to personas](#upgrading-to-personas) belong here.
71
- 10. **Copies for rollback.** Keep copies of the pre-persona `config.json`, the crontable, the `/interject` callers (the host crontab's `curl` lines included), `access.json` and the Slack token environment variables the old CSCB uses. The old CSCB keeps `config.json`, the crontable and `access.json` in the old state directory, `~/.claude/channels/slack`: keep the copies outside it, because the first start in step 10 moves that directory. Record the old CSCB's exact version in the switch-over log beside them.
70
+ 9. **Stage the new release without installing it.** Choose its exact version, record it in the switch-over log and confirm that it is available to install. On an existing-persona host (0.11.0 or later), retain the current persona configuration and credentials files, persona keys, `working_directory`, `claude_config_dir`, `resume_enabled` and state paths; check the existing call timeout against item 7. Nothing is installed over the global package yet, so step 3 uses the old package's own stop command against its current files. The rest of this item applies only to a pre-persona host (0.10.x). Write the persona configuration, with `agent_director_call_timeout_ms`, in a separate file, never `config.json`, which the old CSCB reads. Prepare the Slack apps. Nothing is installed over the global package yet, so step 3's `stop --stop-bots` is the old version's own and reads the old, pre-persona `config.json` from the old state directory, `~/.claude/channels/slack`, where the old package keeps it. In the staged file, name each persona's `credentials_file` in that directory too, never under `~/.config/claude-slack-channel-bots` (step 7 says why). Steps 1, 2, 3, 5 and 6 of [Upgrading to personas](#upgrading-to-personas) belong here.
71
+ 10. **Copies for rollback.** On an existing-persona host (0.11.0 or later), back up the current persona `config.json`, credentials files, crontable, `/interject` callers and state files, including `config.json.last-applied` and `retired-keys.json` when present, outside the state directory. Record their current paths for restoration. On a pre-persona host (0.10.x), keep copies of the pre-persona `config.json`, the crontable, the `/interject` callers (the host crontab's `curl` lines included), `access.json` and the Slack token environment variables the old CSCB uses. The old CSCB keeps `config.json`, the crontable and `access.json` in the old state directory, `~/.claude/channels/slack`: keep the copies outside it, because the first start in step 10 moves that directory. Record the old CSCB's exact version in the switch-over log beside them.
72
72
  11. **The orchestrator prompt (operator action).** The shared orchestrator prompt's "ship now" wording may go out any time before the switch-over. The shared orchestrator prompt is the system prompt your orchestrator bots load, for example the file `append_system_prompt_file` names.
73
73
 
74
74
  #### Step 2: Disable CSCB's autostart
@@ -83,7 +83,7 @@ With the old CSCB still installed, run its own stop:
83
83
  claude-slack-channel-bots stop --stop-bots
84
84
  ```
85
85
 
86
- This is the old version's own `stop --stop-bots`, which reads the old, pre-persona `config.json` from the old state directory, `~/.claude/channels/slack`, where the old package keeps it, since nothing new is installed before step 7. An operator arriving from a startup refusal has first reinstalled the previous CSCB (see [Arrived here from a startup refusal?](#arrived-here-from-a-startup-refusal)), so this holds for them too. From here until step 10, no bot server runs.
86
+ On an existing-persona host (0.11.0 or later), this is the old package's own stop command, reading its current persona configuration and state paths. On a pre-persona host (0.10.x), this is the old version's own `stop --stop-bots`, which reads the old, pre-persona `config.json` from the old state directory, `~/.claude/channels/slack`, where the old package keeps it, since nothing new is installed before step 7. An operator arriving from a startup refusal has first reinstalled the previous CSCB (see [Arrived here from a startup refusal?](#arrived-here-from-a-startup-refusal)), so this holds for them too. From here until step 10, no bot server runs.
87
87
 
88
88
  #### Step 4: Wait for the old rows to end
89
89
 
@@ -125,41 +125,53 @@ A leftover that cannot be ended this way means no Phase 1 install: take step 8's
125
125
  ```sh
126
126
  bun install -g claude-slack-channel-bots@<the version recorded in step 1>
127
127
  ```
128
- 2. Put the persona configuration in place as `config.json`, in the old state directory, `~/.claude/channels/slack`, which the new CLI keeps using until the new CSCB's first start.
129
- 3. Write each persona's credentials file with the new CLI (see [`claude-slack-channel-bots credentials`](cli.md#claude-slack-channel-bots-credentials)), into the same directory: each `credentials_file` in the staged configuration names a path there.
130
- 4. Rewrite crontable targets and `/interject` callers to name personas, in the crontable in the same directory.
128
+ 2. On an existing-persona host (0.11.0 or later), retain its current persona configuration and paths. On a pre-persona host (0.10.x), put the persona configuration in place as `config.json`, in the old state directory, `~/.claude/channels/slack`, which the new CLI keeps using until the new CSCB's first start.
129
+ 3. On an existing-persona host, retain its credentials files and paths. On a pre-persona host (0.10.x), write each persona's credentials file with the new CLI (see [`claude-slack-channel-bots credentials`](cli.md#claude-slack-channel-bots-credentials)), into the same directory: each `credentials_file` in the staged configuration names a path there.
130
+ 4. On an existing-persona host, retain the current crontable and `/interject` callers. On a pre-persona host (0.10.x), rewrite crontable targets and `/interject` callers to name personas, in the crontable in the same directory.
131
131
  5. Run the new install check (see [Checking your agent-director install](agent-director.md#checking-your-agent-director-install)). An older CLI is refused by the 0.12.0 client; do not start CSCB until step 8 installs the required CLI.
132
132
 
133
- Steps 4, 7 and 8 of [Upgrading to personas](#upgrading-to-personas) belong here. From this step the new CSCB is deployed but not started.
133
+ On a pre-persona host (0.10.x), steps 4, 7 and 8 of [Upgrading to personas](#upgrading-to-personas) belong here. From this step the new CSCB is deployed but not started.
134
134
 
135
- **Steps 7 and 8, the call-timeout edit included, must not create `~/.config/claude-slack-channel-bots`.** The credentials command makes the directory of a `credentials_file` that is missing, so a path under `~/.config/claude-slack-channel-bots` creates it. The new CSCB's first start moves the old state directory to `~/.config/claude-slack-channel-bots` in one rename when that does not exist; when it exists, the start merges the old directory into it entry by entry, and a file that differs in the two is a conflict that leaves the old directory in place.
135
+ For a host whose state has not yet moved to the current directory, **steps 7 and 8, the call-timeout edit included, must not create `~/.config/claude-slack-channel-bots`.** The credentials command makes the directory of a `credentials_file` that is missing, so a path under `~/.config/claude-slack-channel-bots` creates it. The new CSCB's first start moves the old state directory to `~/.config/claude-slack-channel-bots` in one rename when that does not exist; when it exists, the start merges the old directory into it entry by entry, and a file that differs in the two is a conflict that leaves the old directory in place.
136
136
 
137
137
  #### Step 8: Install agent-director Phase 1
138
138
 
139
139
  1. **The go-ahead (operator action).** Confirm that the go-ahead for this host still stands, and record it in the switch-over log.
140
140
 
141
- **No go.** If there is no go-ahead, there is no Phase 1 install. Reinstall the previous CSCB, the version step 1 recorded, with the `config.json`, crontable, `/interject` callers, `access.json` and Slack token environment variables saved in step 1. Start it on the previous compatible CLI and re-enable its autostart (operator action). Every other agent this step already stopped is started again on the previous compatible CLI by its owner (operator action). The runbook stops there.
141
+ **No go.** If there is no go-ahead, there is no Phase 1 install. Reinstall the previous CSCB, the version step 1 recorded, and restore its version-appropriate files at the paths saved in step 1. On an existing-persona host (0.11.0 or later), restore the persona configuration, credentials files, crontable, `/interject` callers and saved state, including `config.json.last-applied` and `retired-keys.json` when backed up. On a pre-persona host (0.10.x), restore the `config.json`, crontable, `/interject` callers, `access.json` and Slack token environment variables saved in step 1. Start it on the previous compatible CLI and re-enable its autostart (operator action). Every other agent this step already stopped is started again on the previous compatible CLI through its launcher (operator action). The runbook stops there.
142
142
  2. **Stop every other agent (operator action).** Otherwise, before the install, disable everything that starts agents and stop every other agent on the host: orchestrators' workers, hand-started sessions and sessions with an `agent-director serve`. Stop every long-running AD process and every caller, including TypeScript clients, before the installer runs. Use the still-installed CLI's `kill` and `find-missing` to reconcile stopped agents, using step 6's version-specific identifiers and flags. Confirm with `agent-director list` that all rows are non-live (`ended` or `missing`), with a read-only `tmux ls` that no agent session is left, and with process checks that all workers, callers, serves and launchers are stopped. Record it in the switch-over log. An unresolved live row or a process that cannot be stopped means "no go": do not run the installer, delete rows or edit SQL to fake a terminal state. No agent may run across the upgrade; the installer enforces this with `ErrAgentsLive`.
143
143
  3. **Back up the store and locate the original install.** Run from a shell agent-director did not start, in the directory the original installer ran in (or the same repository). Before the move, the default store is `~/.agent-director/state.db`. For an external `[store] db_path`, save `state.db`, `state.db-wal` and `state.db-shm` while all callers are stopped; the installer migrates an external store in place and rollback needs that copy. Keep the original installer flags and registrations in the switch-over log.
144
144
  4. **Install released agent-director 0.12.0 with its own installer.** Follow [agent-director's installer instructions](https://github.com/gabemahoney/agent-director/blob/v0.12.0/skills/install-agent-director/SKILL.md). Do not copy binaries by hand or create templates in the new directory first. The installer moves `~/.agent-director` to `~/.config/agent-director`, keeps the original as `~/.agent-director.pre-move-<timestamp>`, migrates the store to schema 9, and migrates config and templates. Preserve that kept directory until rollback is no longer wanted. Fix any manual PATH, hook or MCP entries the installer reports, including entries under other Claude config directories; confirm `agent-director version` reports 0.12.0 or later and the new `config_dir`.
145
145
  5. **Write the `[tmux]` values.** Right after the install, before the restarts that follow and so before the new CSCB starts, write any non-default `[tmux]` values you want into `~/.config/agent-director/config.toml`. A long-running agent-director process reads the file only when it starts.
146
146
  6. **Restart callers (operator action).** Restart every `agent-director serve` process and every caller, including TypeScript clients. Compare process start times to confirm that none is older than the install. Record the version and that result in the switch-over log.
147
- 7. **Check the settings again.** Read the nine timing settings and `[pause] timeout_seconds` again as in step 1, and record the effective values. Confirm them and the call timeout as in step 1; if the need has grown, raise `agent_director_call_timeout_ms` in `config.json` before step 10, in the old state directory's copy (step 7's warning applies). Confirm that `agent-director list` answers without `ErrConfigMalformed`.
147
+ 7. **Check the settings again.** Read the nine timing settings and `[pause] timeout_seconds` again as in step 1, and record the effective values. Confirm them and the call timeout as in step 1; if the need has grown, raise `agent_director_call_timeout_ms` in `config.json` before step 10, in the host's current configuration at its existing path (step 7's warning applies if the state directory has not yet moved). Confirm that `agent-director list` answers without `ErrConfigMalformed`.
148
148
 
149
149
  #### Step 9: Start the other agents again
150
150
 
151
- Every other agent on the host is started again by its owner (operator action), which also starts its `serve` processes on the Phase 1 binary.
151
+ Every other agent on the host is started again through its launcher (operator action), which also starts its `serve` processes on the Phase 1 binary.
152
152
 
153
153
  #### Step 10: Start the new CSCB
154
154
 
155
155
  1. **The orchestrator prompt's worker cleanup (operator action).** Before the new CSCB starts, change worker cleanup in the shared orchestrator prompt, and in its source copy if you keep one, from row-delete cleanup to "kill, then leave the row". This change goes out in the same deploy as agent-director Phase 1. Kill-then-leave works on 0.10.0 too, so a rollback does not revert it.
156
156
  2. **Start the new CSCB**, then re-enable the host's autostart for CSCB (operator action):
157
+
158
+ On a host already running personas (0.11.0 or later), check each persona before starting:
159
+ ```sh
160
+ agent-director get --instance-id cscb_<key>
161
+ ```
162
+ Record the row's state, labels and `session_id`. Keep the persona's key, `working_directory` and `claude_config_dir` unchanged, and confirm on the pinned tmux socket that no leftover session has the persona's session name. Record the results in the switch-over log.
163
+
164
+ Start the server:
157
165
  ```sh
158
166
  claude-slack-channel-bots start
159
167
  ```
160
- The first start has no last-applied record yet, so it checks `config.json`, records it and applies it; after that, edits wait until you apply them (see [Reload](reload.md#reload)). Each persona starts fresh once. Pre-persona rows are kept and never resumed.
168
+ Every earlier-release bot starts a fresh conversation once, including bots on a host already running personas (0.11.0 or later). Memory files are kept; the old conversation context is not retained. The replacement row carries `tag_prompt=1`. A row without the marker is never resumed or typed into. A live row read `working` or `check_permission` is left alone until eligible; one read `waiting`, `pending` or `ask_user` is replaced through the checked recovery path.
161
169
 
162
- This start also moves the old state directory: `~/.claude/channels/slack` is renamed to `~/.config/claude-slack-channel-bots`, a symbolic link to it is left at the old name, and the server logs one `[slack] state-migrate: moved the state directory from …` line to `~/.config/claude-slack-channel-bots/server.log`. Edit `config.json` and the other state files through `~/.config/claude-slack-channel-bots` after it.
170
+ On a host switching over from a pre-persona release (0.10.x), the first start has no last-applied record yet, so it checks `config.json`, records it and applies it; after that, edits wait until you apply them (see [Reload](reload.md#reload)). Each persona starts fresh once during this upgrade. Pre-persona rows are kept and never resumed.
171
+
172
+ Check each replacement with the `agent-director get` command above: it must carry the marker and a new `session_id`. Record the result in the switch-over log. Later restarts resume the marked replacement when `resume_enabled` is enabled, the row is `ended` or `missing`, its current `session_id` is non-empty and `transcript_status: present`. For that later resume, check `server.log` for `spawnForPersona: attempting resume for …`. agent-director 0.12.0 resumes only the current session: it no longer falls back to an older session's transcript when the current one is missing.
173
+
174
+ A host already using the current state directory keeps that path; with `SLACK_STATE_DIR` set, nothing moves. On a pre-persona host (0.10.x) still using the old default path, this start also moves the old state directory: `~/.claude/channels/slack` is renamed to `~/.config/claude-slack-channel-bots`, a symbolic link to it is left at the old name, and the server logs one `[slack] state-migrate: moved the state directory from …` line to `~/.config/claude-slack-channel-bots/server.log`. Edit `config.json` and the other state files through `~/.config/claude-slack-channel-bots` after it. An existing-persona host still using an earlier default state directory follows the state-directory move described in [Migration](#migration); keep its configuration and credentials paths intact.
163
175
  3. **The post-install check (operator action).** Once every agent has been started again, confirm that `agent-director list --state pending` shows a `launch_started_at` on every row. Record the result in the switch-over log. A row without one is a human's to look at, and the persona whose row it is is held (see "A persona posts a *Held: launch start not recorded* notice" and "How a hold ends" in [Troubleshooting](troubleshooting.md#troubleshooting)).
164
176
 
165
177
  #### Step 11: Schedule the daily expire
@@ -227,17 +239,17 @@ As the workers' user, on the socket switch-over step 1 pinned, run a read-only `
227
239
  - **A leftover whose row is live:** end it with `agent-director kill --instance-id cscb_<key>`, and check its result; on an error, don't delete or respawn. When that `kill` is refused with "not this launch's session", handle it as "Operator actions" describes.
228
240
  - **A finished row's own leftover session:** handle it as "Operator actions" describes.
229
241
  - **An operator action refused inside the stopping window or the starting-session bound:** retry it once the longer of the two has passed. Read all nine timing settings again, as in switch-over step 1, for the two windows' values, and record them in the switch-over log. A plain `kill` never refuses for that reason.
230
- - **A leftover with no row:** handle it as in [switch-over step 5](#step-5-check-for-leftover-sessions), against the previous CSCB's session names (`slack_bot_<name>_<channel>` or `slack_bot_<channel ID>`) instead of the new personas'.
242
+ - **A leftover with no row:** handle it as in [switch-over step 5](#step-5-check-for-leftover-sessions), against the previous CSCB's session names: `slack_bot_<key>` for an existing-persona release (0.11.0 or later); for a pre-persona release (0.10.x), the previous CSCB's session names (`slack_bot_<name>_<channel>` or `slack_bot_<channel ID>`) instead of the new personas'.
231
243
 
232
- Check each result, then run `agent-director find-missing` and switch-over step 5's gone check again. Don't go on while a leftover remains: the previous CSCB's first start deletes every row without a `channel` label.
244
+ Check each result, then run `agent-director find-missing` and switch-over step 5's gone check again. Don't go on while a leftover remains. When rolling back to a pre-persona release (0.10.x), the previous CSCB's first start deletes every row without a `channel` label. This row-deletion warning does not apply when restoring an existing-persona release (0.11.0 or later).
233
245
 
234
246
  #### Step 6: Stop every agent and restore the previous agent-director
235
247
 
236
248
  1. **Stop every other agent (operator action).** Disable everything that starts agents. Before the previous binary is restored, stop every other agent on the host and every long-running agent-director process: orchestrators' workers, hand-started sessions and sessions with an `agent-director serve`. A `serve` runs inside its Claude session, so that session is stopped too. Stop every caller, including TypeScript clients, as well. Confirm as in [switch-over step 8](#step-8-install-agent-director-phase-1): `agent-director list` shows each stopped agent's row `ended` or `missing`, and a read-only `tmux ls` shows no agent session left. Record it in the switch-over log.
237
249
 
238
- The restore does not begin while any agent runs. Every row must be non-live and every worker, session, caller, serve and launcher stopped before restoration. An unresolved row means no go; do not delete rows or edit SQL to fake a terminal state. If one cannot be stopped, the rollback stops as step 4 says, and every agent this step stopped is started again on Phase 1 by its owner (operator action).
250
+ The restore does not begin while any agent runs. Every row must be non-live and every worker, session, caller, serve and launcher stopped before restoration. An unresolved row means no go; do not delete rows or edit SQL to fake a terminal state. If one cannot be stopped, the rollback stops as step 4 says, and every agent this step stopped is started again on Phase 1 (operator action).
239
251
  2. **Restore the previous agent-director** using [the released 0.12.0 way-back procedure](https://github.com/gabemahoney/agent-director/blob/v0.12.0/docs/operator-actions.md#going-back-to-the-previous-release). Set the new directory aside and restore the kept `~/.agent-director.pre-move-*` directory to `~/.agent-director`; restore any external store from its pre-upgrade copy. Restore registrations, hooks and paths with the previous release's own installer and its original flags, following that procedure. Schema 9 has no downgrade recipe: a binary swap or SQL column removal cannot roll it back. Data written since the upgrade is lost. Restart only callers compatible with the restored CLI.
240
- 3. **Start the other agents again (operator action).** Every other agent on the host is started again by its owner on the previous binary, which also starts its `serve` processes on it.
252
+ 3. **Start the other agents again (operator action).** Every other agent on the host is started again on the previous binary, which also starts its `serve` processes on it.
241
253
 
242
254
  #### Step 7: Revert the orchestrator prompt's hold wording
243
255
 
@@ -245,13 +257,13 @@ Revert the shared orchestrator prompt's "hold until after" wording that switch-o
245
257
 
246
258
  #### Step 8: Reinstall the previous CSCB
247
259
 
248
- 1. Move aside `config.json.last-applied` and `retired-keys.json` (see [Files beside the config file](reload.md#files-beside-the-config-file)), which are in `~/.config/claude-slack-channel-bots` after the new CSCB's first start.
260
+ 1. Move aside `config.json.last-applied` and `retired-keys.json` from the upgraded run (see [Files beside the config file](reload.md#files-beside-the-config-file)), which are in the host's current state directory after the new CSCB's first start. For rollback to an existing-persona release (0.11.0 or later), restore their switch-over step 1 copies along with the other saved state in item 3; do not discard the previous release's applied configuration or retired-key state.
249
261
  2. Reinstall the previous CSCB, the version switch-over step 1 recorded, or else the version the host ran before, without starting it:
250
262
  ```sh
251
263
  bun install -g claude-slack-channel-bots@<the previous version>
252
264
  ```
253
- 3. Put back what switch-over step 1 saved: the pre-persona `config.json`, the crontable, the `/interject` callers (the host crontab's `curl` lines included), `access.json` and the Slack token environment variables. Put `config.json`, the crontable and `access.json` back in the old state directory, by its old name, `~/.claude/channels/slack`: after the new CSCB's first start that name is a symbolic link to `~/.config/claude-slack-channel-bots`, and the previous CSCB reads its files through the link. Leave the link in place, and do not make a directory at the old name.
254
- 4. Where switch-over step 1's copy of a file is missing, the operator rebuilds the pre-persona file by hand from the persona configuration, reversing the manual conversion in [Upgrading to personas](#upgrading-to-personas). No tooling does this: CSCB ships no conversion tooling in either direction.
265
+ 3. For rollback to an existing-persona release (0.11.0 or later), restore the saved persona configuration, credentials files, crontable, `/interject` callers and state files, including the last-applied and retired-key records when backed up, at the paths recorded in switch-over step 1. Keep the persona keys, `working_directory`, `claude_config_dir` and `resume_enabled` unchanged. For rollback to a pre-persona release (0.10.x), put back what switch-over step 1 saved: the pre-persona `config.json`, the crontable, the `/interject` callers (the host crontab's `curl` lines included), `access.json` and the Slack token environment variables. Put `config.json`, the crontable and `access.json` back in the old state directory, by its old name, `~/.claude/channels/slack`: after the new CSCB's first start that name is a symbolic link to `~/.config/claude-slack-channel-bots`, and the previous CSCB reads its files through the link. Leave the link in place, and do not make a directory at the old name.
266
+ 4. The following manual conversion applies only when rolling back to a pre-persona release (0.10.x). Where switch-over step 1's copy of a file is missing, the operator rebuilds the pre-persona file by hand from the persona configuration, reversing the manual conversion in [Upgrading to personas](#upgrading-to-personas). No tooling does this: CSCB ships no conversion tooling in either direction.
255
267
 
256
268
  #### Step 9: Start the previous CSCB
257
269
 
@@ -263,6 +275,8 @@ claude-slack-channel-bots start
263
275
 
264
276
  ### Upgrading to personas
265
277
 
278
+ After upgrading, every bot starts one fresh conversation; memory files are kept. Remove the Claude Code version pin after the upgrade.
279
+
266
280
  This major version accepts only the persona configuration format. These are the configuration steps of an upgrade from an earlier major version. Do them inside the switch-over runbook, not on their own: steps 1, 2, 3, 5 and 6 (the persona file, names, reply settings, Slack apps and who can reach each persona) at [step 1 of the switch-over](#step-1-check-the-host-and-stage-the-release), and steps 4, 7 and 8 (credentials files, crontable and `/interject` callers) at [step 7 of the switch-over](#step-7-install-the-new-cscb-without-starting-it). At switch-over step 1, these steps are written into the separate staged file, not `config.json`; step 7 puts that file in place as `config.json`. Until the new CSCB's first start, the new CLI keeps using the old state directory, `~/.claude/channels/slack`, so the persona `config.json`, the credentials files and the crontable go there; the first start moves that directory to `~/.config/claude-slack-channel-bots`.
267
281
 
268
282
  1. **Rewrite `config.json` by hand.** A configuration from an earlier major version stops the server at start, with an error that names the offending setting and says the configuration must be converted to personas. Nothing is converted automatically and the file is not changed. Write a `personas` list as described in [Personas (config.json)](configuration.md#personas-configjson); the server-wide settings keep their names. The `debug-slack-channel-bots` skill covers this error under "Pre-persona configuration".
@@ -276,12 +290,13 @@ This major version accepts only the persona configuration format. These are the
276
290
 
277
291
  ### Downgrading to an earlier release
278
292
 
279
- An earlier release's loader rejects `allow_invited_channels` and `invited` as unknown keys, and a restarted server runs the last-applied record, so the record must not hold either key when the older build starts. A host whose `config.json` has never held either key downgrades as from any release: install the older build and restart the server on it. Otherwise, follow these steps in order, with the server running:
293
+ Earlier releases ignore this release’s queue and can resume a bot without their required channels launch, leaving it unable to receive messages. Follow all four phases in order, including when the configuration already fits the target release.
280
294
 
281
- 1. **Give every persona a valid declarative section.** Turning fungible mode off is checked by the declarative rules, so each persona needs a `permission_prompts`, and `channels` unless its `dm.enabled` is `true`, that pass them (see [Load-time rules](configuration.md#load-time-rules)).
282
- 2. **Remove `allow_invited_channels` and every persona's `invited` from `config.json`.**
283
- 3. **Confirm the pending change, and wait for its `reload-applied` or `reload-noop` line** (see [Confirming a change](reload.md#confirming-a-change)). Either one means the last-applied record holds the configuration without the two keys. A host that was already in declarative mode confirms as `reload-noop`.
284
- 4. **Install the older build and restart the server on it.** For example, `bun install -g claude-slack-channel-bots@<version>`, then `claude-slack-channel-bots stop` and `claude-slack-channel-bots start`. The older build ignores `channel-delivery.json`, so the file can stay where it is.
295
+ 1. **Stop sending and empty the queues.** Wait until `server.log` shows every received item delivered. Otherwise explicitly accept that undelivered messages are lost when using the earlier release.
296
+ 2. **Disable resume on the current release.** Set `resume_enabled` to `false` in `config.json`, [confirm the pending change](reload.md#confirming-a-change), and verify `config.json.last-applied` holds `resume_enabled: false`. Keep the current server running until this is applied.
297
+ If the target release does not support these settings, first give every persona a valid declarative section (`permission_prompts`, and `channels` unless `dm.enabled` is `true`; see [Load-time rules](configuration.md#load-time-rules)), remove `allow_invited_channels` and every persona’s `invited`, confirm, and ensure the last-applied record holds neither. These precautions do not replace any phase.
298
+ 3. **Stop with the bots, then install and pin the earlier build.** Run `claude-slack-channel-bots stop --stop-bots` and verify it succeeds, install the earlier build (for example `bun install -g claude-slack-channel-bots@<version>`), then restore its required Claude Code version pin before starting it.
299
+ 4. **Start the earlier release fresh with channels, then restore resume.** Run `claude-slack-channel-bots start`; verify every bot starts a new conversation with that release’s channels launch and no configuration load error. Only then set `resume_enabled` back to its previous value and confirm the change. The earlier build ignores `channel-delivery.json`; leave it in place.
285
300
 
286
301
  ---
287
302
 
package/docs/reload.md CHANGED
@@ -14,6 +14,8 @@ These files sit in the same directory as `config.json` (`~/.config/claude-slack-
14
14
  | `retired-keys.json` | The retired-key record: the persona keys the server holds as retired. A confirmed change records the key of each removed persona (a renamed persona's old key included) and of each persona whose `name`, `credentials_file` or `working_directory` changed, before `config.json.last-applied` is rewritten. A server start also records, before its clean-up of old instances ends any of them, the key of every agent-director instance labelled with a persona that is not in the applied configuration (for example, a persona that `config.json` no longer names, at a start after `config.json.last-applied` was deleted; see [Start rules](#start-rules)), so a persona added back later with that key starts fresh. If that write fails, the start logs it in `server.log`, holds those keys as retired until the server stops, and records them again at the next start. Only the server writes it, never the CLI. It is kept across restarts and is absent until a key is first recorded. A key's entry is removed on its own once the persona's new session is running. If it can't be read, the server doesn't start; moving it aside is the fix (see `retired-keys-unreadable` under [Startup errors](troubleshooting.md#startup-errors)). |
15
15
  | `channel-delivery.json` | The stored-choice file: each persona's stored channel-delivery choices (see [Channel delivery in fungible mode](channel-modes.md#channel-delivery-in-fungible-mode)), by persona key and channel, with each persona's `name`, `credentials_file` and `working_directory`. It holds no token and no message text. Only the server reads and writes it, never the CLI. It is absent until the first accepted `set_channel_delivery` call, so it is never created in declarative mode. Never edit it by hand. The server reads it once at each start. A persona's choices are dropped when a confirmed change retires its key (the persona is removed, or its `name`, `credentials_file` or `working_directory` changes), and at a start when its key is not an applied persona, its `name`, `credentials_file` or `working_directory` differs from the one stored, or `retired-keys.json` holds its key as retired and the persona's new session has not yet begun; each drop logs a `[slack] channel-delivery:` line. If the file can't be read, parsed or validated at a start, the server leaves it in place, logs one [`channel-delivery-unreadable`](../skills/debug-slack-channel-bots/SKILL.md#channel-delivery-unreadable) line, and starts and runs every persona, but for that run no stored choice applies: in fungible mode every channel is at `mentions`, `set_channel_delivery` is refused, and nothing is written to the file or dropped from it. To fix it, move the file aside, then restart the server; this discards every stored choice. If you restore that same file in place instead of moving it aside, a persona removed and added back with the same `name`, `credentials_file` and `working_directory` while it was unreadable gets its earlier choices again. |
16
16
 
17
+ | `queue/<key>.jsonl` | The persona’s durable queue, holding message text until delivery. Removing a persona retains its queue file; delivery resumes only when the same key returns. |
18
+
17
19
  ## Start rules
18
20
 
19
21
  | At start | What happens |
@@ -60,7 +62,7 @@ claude-slack-channel-bots: pending configuration change (written by the server)
60
62
  fingerprint: sha256:<64 hex digits>
61
63
 
62
64
  A configuration change is pending; nothing has been applied. personas: 1 added, 1 removed, 0 destructively modified, 1 modified in place, 1 with changed credentials; server-wide settings: 2 changed.
63
- DESTRUCTIVE: persona "scribe" (key=scribe) is removed: the persona will be retired: its session stopped and never resumed.
65
+ DESTRUCTIVE: persona "scribe" (key=scribe) is removed: the persona will be retired: its session stopped and never resumed; 2 open queued items kept, delivered only if a persona with the same key is added again.
64
66
  persona "helper" (key=helper) is added but cannot come up: working directory does not exist.
65
67
  persona "planner" (key=planner): channels changed: applied in place immediately, instance kept.
66
68
  persona "reviewer" (key=reviewer): credentials file "/home/operator/.config/claude-slack-channel-bots/reviewer-credentials.json" changed: a new connection opens, then the old one closes, instance kept.
@@ -156,7 +158,7 @@ To make them take effect sooner, wait for the `reload-applied` line, then run `c
156
158
 
157
159
  ## Destructive changes
158
160
 
159
- `DESTRUCTIVE:` lines in the preview name the personas the change retires when you confirm it (see the table above). A retired persona's session is stopped and never resumed. Read the preview before you rename it. There is no undo. A persona removed and added again with the same key, or renamed and then renamed back, always starts a new conversation, even with a server restart in between: its old conversation is never resumed. A persona brought up from its new entry after a `credentials_file` path or `working_directory` change starts fresh the same way.
161
+ `DESTRUCTIVE:` lines in the preview name the personas the change retires when you confirm it (see the table above). A retired persona's session is stopped and never resumed. Its queue is retained: add a persona with the same key to deliver its queued messages. Read the preview before you rename it. There is no undo. A persona removed and added again with the same key, or renamed and then renamed back, always starts a new conversation, even with a server restart in between: its old conversation is never resumed. A persona brought up from its new entry after a `credentials_file` path or `working_directory` change starts fresh the same way.
160
162
 
161
163
  CSCB never deletes an agent-director row, and it checks the result of every kill it makes. A teardown ends the old session, checks the result, and keeps the row; a removed persona's row stays until agent-director's `expire` removes it, and is never resumed. A kill agent-director can't carry out right now is tried up to 3 times, 2 s apart. When the kill still fails (for example, the worker outlived each of its kills), `server.log` shows the teardown's line for it, which ends `— the row is kept; nothing latches and no retry timer is armed …`. The persona's new session is then not started over the old one: the bring-up after a `credentials_file` path or `working_directory` change ends the old session first and starts the new conversation only once that succeeds. A renamed persona's new name is brought up only once the old instance has ended, and so is any other persona that shares the old instance's working directory; until then a Claude Code session started from that directory is refused (see "A persona waits on an old instance in its working directory" in [Troubleshooting](troubleshooting.md#troubleshooting)). The old conversation is never resumed either way.
162
164
 
@@ -170,7 +172,7 @@ To fix it, wait for the server to write `config.json.pending` again (within abou
170
172
 
171
173
  ## Size limit
172
174
 
173
- `config.json`, the files beside it and each credentials file are read only up to 64 KiB; a larger file is treated as unreadable. The retired-key record (`retired-keys.json`) and the stored-choice file (`channel-delivery.json`) have no size cap: only the server writes them. If a very large change makes `config.json.pending` itself larger, its rename is refused as stale, so split the change into smaller edits.
175
+ `config.json`, the files beside it and each credentials file are read only up to 64 KiB; a larger file is treated as unreadable. The retired-key record (`retired-keys.json`) and the stored-choice file (`channel-delivery.json`) have no size cap: only the server writes them. The queue is also read in full with no size cap (`queue/<key>.jsonl`). If a very large change makes `config.json.pending` itself larger, its rename is refused as stale, so split the change into smaller edits.
174
176
 
175
177
  ## No reload command
176
178
 
@@ -1,6 +1,6 @@
1
1
  # Scheduled Prompts (cscb_cron)
2
2
 
3
- The server fires scheduled prompts into personas once per minute, reading them from a crontable. Each fire is delivered as an `/interject` message to the target persona, exactly as if a script had POSTed it.
3
+ The server fires scheduled prompts into personas once per minute, reading them from a crontable. Each fire is stored in the target persona’s queue as its own injected message.
4
4
 
5
5
  ## The crontable
6
6
 
@@ -91,11 +91,11 @@ The prompt-file path resolves as follows:
91
91
 
92
92
  ## How fires appear
93
93
 
94
- A scheduled fire reaches only the target persona's instance, as an `/interject` message with no Slack conversation attached. Its `sender` label is `cscb-cron:<prompt-file-basename>` — for a prompt file `standup.md` the sender is `cscb-cron:standup`. This distinguishes a cron tick from a human and from peer-bot traffic. The [Slack Reply Guard](bots.md#slack-reply-guard-stop-hook) never reminds a bot to reply to a scheduled prompt: like every injected message, a fire carries no `via`. Because a fire carries no channel, a prompt file that wants a Slack reply must say where to post it. Each schedule delivers its own message independently, so when several schedules match the same minute for the same persona each one arrives as its own `/interject` message.
94
+ The server directly queues a scheduled fire only for the target persona, as an injected message with no Slack conversation attached. Its `sender` label is `cscb-cron:<prompt-file-basename>` — for a prompt file `standup.md` the sender is `cscb-cron:standup`. This distinguishes a cron tick from a human and from peer-bot traffic. The [Slack Reply Guard](bots.md#slack-reply-guard-stop-hook) never reminds a bot to reply to a scheduled prompt: like every injected message, a fire carries no `via`. Because a fire carries no channel, a prompt file that wants a Slack reply must say where to post it. Each schedule queues its own message independently, so when several schedules match the same minute for the same persona each one is its own queued item.
95
95
 
96
96
  ## The cron log
97
97
 
98
- Every fire outcome is recorded in the cron log at `cron_log_path` (default `<config dir>/cron.log`, beside `config.json` wherever the crontable is; override with the `cron_log_path` key). Each attempt writes one line per persona targeted (showing the target as first written in the crontable), plus a per-fire summary line carrying `delivered=N failed=M` counts. The log is plain text, so `grep no-session cron.log` yields readable lines.
98
+ Every fire outcome is recorded in the cron log at `cron_log_path` (default `<config dir>/cron.log`, beside `config.json` wherever the crontable is; override with the `cron_log_path` key). Each attempt writes one line per persona targeted (showing the target as first written in the crontable), plus a per-fire summary line carrying `queued=N failed=M` counts. The log is plain text, so `grep queue-failed cron.log` yields readable lines.
99
99
 
100
100
  By default the log only grows. To cap it, set `cron_log_max_bytes` in `config.json`: once the log passes that many bytes, its oldest whole lines are dropped, so the oldest fires disappear from it while the newest stay. There is no rotated copy; copy the log elsewhere if you need a longer history. Don't move or delete it yourself: the server keeps writing to the old file until that file passes the cap. A changed cap takes effect at the next server start.
101
101
 
@@ -103,14 +103,13 @@ The `outcome` field of each line is one of these classes:
103
103
 
104
104
  | Outcome | What happened | What to do |
105
105
  |---|---|---|
106
- | `delivered` | The prompt reached the target persona's session. | Nothing — success. |
107
- | `no-session` | The persona exists but is not up (broken or retrying) or has no live connected session, so the message was dropped. | Check the persona's lines in the server log for the cause. A retrying persona comes up on its own; otherwise bring the session up. Failed fires are **never** retried or queued (see below). |
106
+ | `queued` | The prompt was durably stored for the target persona. | Nothing — storage succeeded; this is not a delivery confirmation. |
107
+ | `queue-failed` | The prompt could not be stored. | Check storage availability and permissions in the server log. |
108
108
  | `unknown-persona` | No persona in the applied configuration has that name or key. | Correct the target in the crontable. |
109
109
  | `prompt-missing` | The prompt file did not exist at fire time. | Create the file or correct its path in the crontable. |
110
110
  | `prompt-unreadable` | The prompt file existed but could not be read (see the `errno`). | Fix file permissions or the path. |
111
111
  | `prompt-oversize` | The prompt exceeds the 32KB `/interject` cap and was skipped, never truncated. | Shorten the prompt file. |
112
112
  | `parse-error` | The crontable line could not be parsed. | Fix the line — see [Crontable format](#crontable-format). |
113
- | `http-error` | The localhost POST hit an unexpected HTTP status or a network failure. | Check that the server is listening on loopback (see the `bind` note below) and inspect the `errno`/`status` in the line. |
114
113
  | `fanout-deferred` | A line with no persona list (all-bots) was matched but not delivered. | Add a persona list to the line — all-bots delivery is not built yet. |
115
114
 
116
115
  Each load of the crontable, at start and after each edit, writes one line with its counts. At start it is `scheduler started, N schedules loaded, M undeliverable (no persona list)`; after an edit it is `crontable reloaded, N schedules, M undeliverable (no persona list)`. `M` counts the lines that will never be delivered, and it is written even when it is 0.
@@ -129,13 +128,12 @@ grep undeliverable ~/.config/claude-slack-channel-bots/cron.log
129
128
 
130
129
  ## Delivery semantics
131
130
 
132
- - **No retry.** A failed fire is logged and dropped — never queued or re-sent. A persona that is not up, or has no live session, fails every fire until it is up and its session is running again; the server does not queue the missed prompts.
131
+ - **Accepted fires stay queued.** A configured persona can accept prompts while it is down or disconnected. Every accepted fire is an independent item, never merged or dropped, and delivered in FIFO order across all sources, carrying its fire timestamp as `ts`; a failed store is logged as `queue-failed` and is not an accepted item.
133
132
  - **Missed fires are skipped, not caught up.** While the server is down, no scheduled prompts fire, and they are not replayed on restart. The `scheduler started, N schedules loaded, M undeliverable (no persona list)` line in the cron log marks when scheduling resumed, bounding the outage window.
134
133
  - **Edits take effect within a minute — no restart.** The scheduler checks the crontable fresh on every tick, so an edit by hand or a line appended by a bot starts (or stops) firing within about a minute. The server is never restarted for a schedule change.
135
134
  - **Deleting the crontable stops all schedules.** Nothing fires from that moment, a WARN appears in the cron log, and the server re-creates the file empty (with its header) within about two minutes — detection and re-creation happen on separate once-a-minute passes, so the re-create lands up to two tick boundaries after the deletion. Add lines back and they schedule on the next check.
136
135
  - **Server-local time.** Cron expressions are evaluated in the server's local timezone.
137
136
  - **A line without a persona list is never delivered.** Each load logs a `warn` naming it and counts it undeliverable; each time it matches, it is logged `fanout-deferred` and dropped. Give the line a persona list to have it fire.
138
- - **`bind` must include loopback.** The scheduler delivers via `127.0.0.1`, so a `bind` set to a single non-loopback interface makes every fire fail with `http-error`. Use the default `127.0.0.1` or `0.0.0.0`.
139
137
 
140
138
  ## Bot self-scheduling
141
139