claude-slack-channel-bots 0.4.1 → 0.5.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.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # Slack Channel Router
1
+ # Claude Slack Channel Bots
2
2
 
3
3
  A single HTTP MCP server that holds one Slack Socket Mode connection and routes messages to multiple independent Claude Code sessions, each scoped to a different repo and reachable via its own Slack channel. Inbound messages are dispatched to whichever session owns the channel they arrived on; outbound tool calls are restricted to channels that session has previously received a message from.
4
4
 
@@ -56,7 +56,7 @@ Tokens and runtime options are read from environment variables. There is no `.en
56
56
  |---|---|
57
57
  | `SLACK_BOT_TOKEN` | Slack bot token (`xoxb-…`). Required. Granted by the OAuth install flow. |
58
58
  | `SLACK_APP_TOKEN` | Slack app-level token (`xapp-…`). Required. Generated under Basic Information → App-Level Tokens with the `connections:write` scope. |
59
- | `SLACK_STATE_DIR` | Override the directory where `routing.json`, `access.json`, and runtime state are stored. Defaults to `~/.claude/channels/slack`. |
59
+ | `SLACK_STATE_DIR` | Override the directory where `config.json`, `access.json`, and runtime state are stored. Defaults to `~/.claude/channels/slack`. |
60
60
  | `SLACK_ACCESS_MODE` | Set to `static` to load `access.json` once at startup and cache it for the lifetime of the process rather than re-reading it on every event. Useful in high-throughput environments where disk reads are a concern. |
61
61
  | `SLACK_DRY_RUN` | Set to `1` to start the server without Slack credentials. Token validation is skipped, Socket Mode and `web.auth.test()` are not called, and MCP tool calls (`reply`, `react`, etc.) are logged instead of sent. Useful for integration testing. |
62
62
 
@@ -74,9 +74,9 @@ export SLACK_DRY_RUN=1
74
74
 
75
75
  ---
76
76
 
77
- ### Routing (routing.json)
77
+ ### Routing (config.json)
78
78
 
79
- `routing.json` is read from `~/.claude/channels/slack/routing.json` by default. Override the directory with `SLACK_STATE_DIR`.
79
+ `config.json` is read from `~/.claude/channels/slack/config.json` by default. Override the directory with `SLACK_STATE_DIR`.
80
80
 
81
81
  A skeleton file is created by postinstall. Populate it before running `start`.
82
82
 
@@ -116,15 +116,16 @@ A skeleton file is created by postinstall. Populate it before running `start`.
116
116
  | `stop_timeout` | number | `30` | Seconds to wait for the server process to exit after `SIGTERM` before escalating to `SIGKILL`. |
117
117
  | `mcp_config_path` | string | `~/.claude/slack-mcp.json` | Path to the MCP config file passed to Claude Code when launching managed sessions. |
118
118
  | `append_system_prompt_file` | string | — | Path to a file appended to every managed session's system prompt via `--append-system-prompt-file`. Missing file silently skipped. See `skills/EXAMPLE_CLAUDE.md` for a template. |
119
+ | `system_prompt_mode` | string | `"append"` | Controls how `append_system_prompt_file` is applied. `"append"`: the custom prompt file is appended on top of `CLAUDE.md` (default, current behavior). `"none"`: only `CLAUDE.md` is used; `append_system_prompt_file` is ignored even if set. Use `"none"` when the project's `CLAUDE.md` already contains everything the bot needs. |
119
120
  | `cozempic_prescription` | string | `"standard"` | Cozempic cleaning intensity before resume. Valid values: `gentle`, `standard`, `aggressive`. Has no effect if cozempic is not installed. |
120
121
 
121
122
  ---
122
123
 
123
124
  ### Access Control (access.json)
124
125
 
125
- `access.json` is read from `~/.claude/channels/slack/access.json` by default (same directory as `routing.json`). A skeleton file with defaults is created by postinstall. The file is written with `0600` permissions.
126
+ `access.json` is read from `~/.claude/channels/slack/access.json` by default (same directory as `config.json`). A skeleton file with defaults is created by postinstall. The file is written with `0600` permissions.
126
127
 
127
- Channels in `routing.json` are automatically allowed — you do not need to list them here. The `channels` map is only needed for per-channel overrides like requiring @mentions or restricting which users can trigger the bot.
128
+ Channels in `config.json` are automatically allowed — you do not need to list them here. The `channels` map is only needed for per-channel overrides like requiring @mentions or restricting which users can trigger the bot.
128
129
 
129
130
  The `slack-channel-access` skill manages pairings and allowlist entries at runtime.
130
131
 
@@ -153,7 +154,7 @@ The `slack-channel-access` skill manages pairings and allowlist entries at runti
153
154
  |---|---|---|---|
154
155
  | `dmPolicy` | `"pairing"` \| `"allowlist"` \| `"disabled"` | `"pairing"` | Controls who can DM the bot. `pairing`: unknown users receive a one-time code and are added to `allowFrom` after verification. `allowlist`: only users in `allowFrom` are accepted. `disabled`: all DMs are dropped. |
155
156
  | `allowFrom` | string[] | `[]` | Slack user IDs allowed to DM the bot unconditionally (regardless of `dmPolicy`). |
156
- | `channels` | object | `{}` | Optional per-channel overrides. Channels in `routing.json` are allowed automatically — only add entries here to customize behavior (e.g. require @mention or restrict users). Each entry is a `ChannelPolicy`. |
157
+ | `channels` | object | `{}` | Optional per-channel overrides. Channels in `config.json` are allowed automatically — only add entries here to customize behavior (e.g. require @mention or restrict users). Each entry is a `ChannelPolicy`. |
157
158
  | `channels[id].requireMention` | boolean | `false` | When `true`, messages in that channel are only delivered if the bot is `@mentioned`. |
158
159
  | `channels[id].allowFrom` | string[] | `[]` | When non-empty, restricts delivery to the listed Slack user IDs for that channel. |
159
160
  | `pending` | object | `{}` | Managed by the server. Stores in-flight pairing codes indexed by code string. Do not edit manually. |
@@ -178,7 +179,7 @@ Claude Code sessions need a config file pointing at the MCP server. A skeleton i
178
179
  }
179
180
  ```
180
181
 
181
- If you changed `port` or `bind` in `routing.json`, update the `url` here to match. The server-managed session launcher uses `mcp_config_path` from `routing.json` to locate this file.
182
+ If you changed `port` or `bind` in `config.json`, update the `url` here to match. The server-managed session launcher uses `mcp_config_path` from `config.json` to locate this file.
182
183
 
183
184
  ---
184
185
 
@@ -195,7 +196,7 @@ Checks prerequisites, then daemonizes the server.
195
196
  1. `tmux` is on `PATH` — fails with `missing prerequisite: tmux` if not found.
196
197
  2. `SLACK_BOT_TOKEN` is set — fails with `missing prerequisite: SLACK_BOT_TOKEN environment variable` if absent.
197
198
  3. `SLACK_APP_TOKEN` is set — fails with `missing prerequisite: SLACK_APP_TOKEN environment variable` if absent.
198
- 4. `routing.json` exists at `STATE_DIR/routing.json` — fails with the full path if not found.
199
+ 4. `config.json` exists at `STATE_DIR/config.json` — fails with the full path if not found.
199
200
 
200
201
  If all checks pass, the parent process spawns a detached child process and exits immediately, printing the child PID. The child starts the server and writes its PID to `STATE_DIR/server.pid`. Conversation context is preserved across server restarts when possible.
201
202
 
@@ -276,7 +277,7 @@ When Claude Code requires tool approval, the permission relay surfaces an intera
276
277
 
277
278
  The `ask-relay.sh` hook intercepts `AskUserQuestion` tool calls via `PreToolUse`, posts the question and its options to Slack as interactive buttons, and waits for the user's selection. The answer is returned to Claude Code via `updatedInput` without blocking the TUI.
278
279
 
279
- Both hooks are **scope-guarded**: they check for the `SLACK_CHANNEL_BOT_SESSION` environment variable and exit immediately (no-op) if it is not set. The server sets this variable on every Claude session it launches. This means installing the hooks globally in `settings.json` is safe — they will not activate for Claude sessions you run outside the bot.
280
+ Both hooks are **scope-guarded**: on each invocation they call the server's `GET /is-managed?pid=$PPID` endpoint to verify that the calling process belongs to a server-managed Claude session. If the server is not running or does not recognize the PID, the hooks exit silently (no-op). This means installing the hooks globally in `settings.json` is safe — they will not activate for Claude sessions you run outside the bot.
280
281
 
281
282
  Both hooks use a **two-phase long-poll protocol**:
282
283
 
@@ -328,7 +329,7 @@ To enable it: open your Slack app config → **Interactivity & Shortcuts** → t
328
329
 
329
330
  `permission-relay.sh` relays tool permission requests (Allow/Deny) to Slack via `PermissionRequest`. `ask-relay.sh` relays `AskUserQuestion` calls to Slack via `PreToolUse`, returning the user's selection without blocking the TUI.
330
331
 
331
- Both hooks auto-detect the server port from `routing.json`. They read `${SLACK_STATE_DIR:-$HOME/.claude/channels/slack}/routing.json` and use the `port` field (defaulting to `3100`), so they stay in sync if you change the port in routing config.
332
+ Both hooks auto-detect the server port from `config.json`. They read `${SLACK_STATE_DIR:-$HOME/.claude/channels/slack}/config.json` and use the `port` field (defaulting to `3100`), so they stay in sync if you change the port in routing config.
332
333
 
333
334
  ### Setup skill
334
335
 
@@ -341,23 +342,23 @@ The `update-config` skill can automate hook installation. It copies or symlinks
341
342
  **Missing environment variables**
342
343
  `start` exits with `missing prerequisite: SLACK_BOT_TOKEN environment variable` or `SLACK_APP_TOKEN environment variable`. Export both tokens in your shell profile and open a new terminal before running `start`.
343
344
 
344
- **routing.json not found**
345
- `start` exits with `missing prerequisite: routing.json not found at <path>`. Run `bun postinstall.ts` to create a skeleton, or create the file manually. Verify `SLACK_STATE_DIR` matches the directory you populated.
345
+ **config.json not found**
346
+ `start` exits with `missing prerequisite: config.json not found at <path>`. Run `bun postinstall.ts` to create a skeleton, or create the file manually. Verify `SLACK_STATE_DIR` matches the directory you populated.
346
347
 
347
- **routing.json CWD mismatch**
348
- If a Claude Code session connects but immediately disconnects, the session's actual CWD does not match any `cwd` in `routing.json`. Confirm the session's working directory matches the entry exactly (after tilde expansion). Duplicate CWDs across multiple routes are rejected at startup.
348
+ **config.json CWD mismatch**
349
+ If a Claude Code session connects but immediately disconnects, the session's actual CWD does not match any `cwd` in `config.json`. Confirm the session's working directory matches the entry exactly (after tilde expansion). Duplicate CWDs across multiple routes are rejected at startup.
349
350
 
350
351
  **Bot not receiving messages in a new channel**
351
352
  After inviting the bot to a channel, Slack may not deliver messages until the bot is @mentioned for the first time. This is a Slack Socket Mode behavior — the first @mention activates event delivery for that channel. After that, all messages flow normally regardless of `requireMention` settings.
352
353
 
353
354
  **Channel not in access.json**
354
- Messages to channels not listed in `access.json → channels` and not present in `routing.json → routes` are silently dropped. Use the `claude-slack-channels-config` skill or edit `access.json` directly to add the channel ID with a `ChannelPolicy` entry.
355
+ Messages to channels not listed in `access.json → channels` and not present in `config.json → routes` are silently dropped. Use the `claude-slack-channels-config` skill or edit `access.json` directly to add the channel ID with a `ChannelPolicy` entry.
355
356
 
356
357
  **Permission relay not working**
357
- Check that the Slack app has interactivity enabled (Interactivity & Shortcuts → toggle on). Verify `curl` and `jq` are on your `PATH`. Confirm the hook scripts are executable (`chmod +x`). If the port was changed in `routing.json`, ensure `SLACK_STATE_DIR` is set correctly so the hooks can read the updated port. If the hooks are silently doing nothing, confirm the session was launched by the server — the hooks only activate when `SLACK_CHANNEL_BOT_SESSION=1` is present in the environment. Sessions launched manually will not trigger the relay.
358
+ Check that the Slack app has interactivity enabled (Interactivity & Shortcuts → toggle on). Verify `curl` and `jq` are on your `PATH`. Confirm the hook scripts are executable (`chmod +x`). If the port was changed in `config.json`, ensure `SLACK_STATE_DIR` is set correctly so the hooks can read the updated port. If the hooks are silently doing nothing, confirm the session was launched by the server — the hooks call `GET /is-managed?pid=$PPID` and exit silently if the server is unreachable or the PID is not recognized. Sessions launched manually will not trigger the relay.
358
359
 
359
360
  **Session not restarting after crash**
360
- After 3 consecutive launch failures for a route, auto-restart is suspended until the server is restarted. Restart the server with `claude-slack-channel-bots stop && claude-slack-channel-bots start`. To disable auto-restart entirely, set `session_restart_delay` to `0` in `routing.json`.
361
+ After 3 consecutive launch failures for a route, auto-restart is suspended until the server is restarted. Restart the server with `claude-slack-channel-bots stop && claude-slack-channel-bots start`. To disable auto-restart entirely, set `session_restart_delay` to `0` in `config.json`.
361
362
 
362
363
  **Session stuck during clean_restart**
363
364
  If a session does not exit within `exit_timeout` seconds (default 120s), `clean_restart` force-kills its tmux session and proceeds. To manually recover, run `tmux kill-session -t <session-name>` for any remaining sessions, then `claude-slack-channel-bots stop && claude-slack-channel-bots start`.
@@ -2,8 +2,20 @@
2
2
  # AskUserQuestion relay — intercepts via PreToolUse, posts to Slack, returns answer
3
3
  set -euo pipefail
4
4
 
5
- # Guard: only relay for bot-managed sessions
6
- if [ -z "${SLACK_CHANNEL_BOT_SESSION:-}" ]; then
5
+ # Check dependencies (must be before guard since guard needs curl+jq)
6
+ if ! command -v jq &>/dev/null; then
7
+ exit 0
8
+ fi
9
+ if ! command -v curl &>/dev/null; then
10
+ exit 0
11
+ fi
12
+
13
+ # Read port from config.json
14
+ PORT=$(jq -r '.port // 3100' "${SLACK_STATE_DIR:-$HOME/.claude/channels/slack}/config.json" 2>/dev/null) || PORT=3100
15
+
16
+ # Guard: only relay for bot-managed sessions (server PID check)
17
+ HTTP_STATUS=$(curl -sf -o /dev/null -w "%{http_code}" "http://127.0.0.1:${PORT}/is-managed?pid=$PPID" 2>/dev/null) || HTTP_STATUS="000"
18
+ if [ "$HTTP_STATUS" != "200" ]; then
7
19
  exit 0
8
20
  fi
9
21
 
@@ -23,9 +35,6 @@ if [ -z "$QUESTION" ] || [ "$OPTIONS" = "[]" ] || [ -z "$CWD" ]; then
23
35
  exit 0
24
36
  fi
25
37
 
26
- # Read port from routing config
27
- PORT=$(jq -r '.port // 3100' "${SLACK_STATE_DIR:-$HOME/.claude/channels/slack}/routing.json" 2>/dev/null) || PORT=3100
28
-
29
38
  # Phase 1: POST question to server
30
39
  RESPONSE=$(curl -s -f -X POST "http://127.0.0.1:${PORT}/ask" \
31
40
  -H 'Content-Type: application/json' \
@@ -2,11 +2,6 @@
2
2
  # permission-relay.sh - Claude Code PermissionRequest hook
3
3
  # Implements two-phase long-poll to relay permission decisions via Slack channel server
4
4
 
5
- # Guard: only relay for bot-managed sessions
6
- if [ -z "${SLACK_CHANNEL_BOT_SESSION:-}" ]; then
7
- exit 0
8
- fi
9
-
10
5
  # Check dependencies
11
6
  if ! command -v jq &>/dev/null; then
12
7
  exit 0
@@ -15,6 +10,22 @@ if ! command -v curl &>/dev/null; then
15
10
  exit 0
16
11
  fi
17
12
 
13
+ # Read port from config.json, default to 3100
14
+ CONFIG_FILE="${SLACK_STATE_DIR:-$HOME/.claude/channels/slack}/config.json"
15
+ PORT=3100
16
+ if [ -f "$CONFIG_FILE" ]; then
17
+ ROUTED_PORT=$(jq -r '.port // empty' "$CONFIG_FILE" 2>/dev/null) || true
18
+ if [ -n "${ROUTED_PORT:-}" ]; then
19
+ PORT="$ROUTED_PORT"
20
+ fi
21
+ fi
22
+
23
+ # Guard: only relay for bot-managed sessions (server PID check)
24
+ HTTP_STATUS=$(curl -sf -o /dev/null -w "%{http_code}" "http://127.0.0.1:${PORT}/is-managed?pid=$PPID" 2>/dev/null) || HTTP_STATUS="000"
25
+ if [ "$HTTP_STATUS" != "200" ]; then
26
+ exit 0
27
+ fi
28
+
18
29
  # Read stdin
19
30
  INPUT=$(cat)
20
31
 
@@ -23,16 +34,6 @@ TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // ""' 2>/dev/null) || exit 0
23
34
  TOOL_INPUT=$(echo "$INPUT" | jq -c '.tool_input // {}' 2>/dev/null) || exit 0
24
35
  CWD=$(echo "$INPUT" | jq -r '.cwd // ""' 2>/dev/null) || exit 0
25
36
 
26
- # Read port from routing.json, default to 3100
27
- ROUTING_FILE="${SLACK_STATE_DIR:-$HOME/.claude/channels/slack}/routing.json"
28
- PORT=3100
29
- if [ -f "$ROUTING_FILE" ]; then
30
- ROUTED_PORT=$(jq -r '.port // empty' "$ROUTING_FILE" 2>/dev/null) || true
31
- if [ -n "${ROUTED_PORT:-}" ]; then
32
- PORT="$ROUTED_PORT"
33
- fi
34
- fi
35
-
36
37
  BASE_URL="http://127.0.0.1:${PORT}"
37
38
 
38
39
  # Phase 1 — Create permission request
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-slack-channel-bots",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Multi-session Slack-to-Claude bridge \u2014 run multiple Claude Code bots across Slack channels via Socket Mode",
5
5
  "type": "module",
6
6
  "bin": {
@@ -33,7 +33,7 @@ message handling (ack reactions, chunking).
33
33
  ```bash
34
34
  STATE_DIR="${SLACK_STATE_DIR:-$HOME/.claude/channels/slack}"
35
35
  # access.json lives at $STATE_DIR/access.json
36
- # routing.json lives at $STATE_DIR/routing.json
36
+ # config.json lives at $STATE_DIR/config.json
37
37
  ```
38
38
 
39
39
  Always resolve the state directory using `$SLACK_STATE_DIR` with fallback to
@@ -102,12 +102,12 @@ Parse `$ARGUMENTS` and execute the matching subcommand:
102
102
 
103
103
  ### `status`
104
104
  1. Load `access.json`
105
- 2. Load `routing.json` from the same state directory
105
+ 2. Load `config.json` from the same state directory
106
106
  3. Display:
107
107
  - DM policy
108
108
  - Allowlisted user IDs
109
109
  - Opted-in channels with their policies, showing two categories:
110
- - **Implicit** — channels present in `routing.json` routes (automatically opted-in)
110
+ - **Implicit** — channels present in `config.json` routes (automatically opted-in)
111
111
  - **Explicit** — channels configured in `access.json` channels (with their `requireMention` and `allowFrom` settings)
112
112
  - Pending pairings (code + sender ID + expiry)
113
113
  - Ack reaction setting (or "not set")
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: setup-slack-channel-bots
3
- description: Interactive setup wizard for claude-slack-channel-bots — checks tokens, routing.json, access.json, hooks, and Claude Code settings
3
+ description: Interactive setup wizard for claude-slack-channel-bots — checks tokens, config.json, access.json, hooks, and Claude Code settings
4
4
  version: 1.0.0
5
5
  author: Jeremy Longshore <jeremy@intentsolutions.io>
6
6
  license: MIT
@@ -122,14 +122,14 @@ want confirmation.
122
122
 
123
123
  ---
124
124
 
125
- ### Step 4 — Check routing.json
125
+ ### Step 4 — Check config.json
126
126
 
127
127
  State directory defaults to `~/.claude/channels/slack/`. Respect
128
128
  `$SLACK_STATE_DIR` if set.
129
129
 
130
130
  ```bash
131
131
  STATE_DIR="${SLACK_STATE_DIR:-$HOME/.claude/channels/slack}"
132
- cat "$STATE_DIR/routing.json" 2>/dev/null || echo "NOT_FOUND"
132
+ cat "$STATE_DIR/config.json" 2>/dev/null || echo "NOT_FOUND"
133
133
  ```
134
134
 
135
135
  **If the file does not exist:**
@@ -169,7 +169,7 @@ test -d "<expanded-path>" && echo "ok" || echo "not found"
169
169
  Expand `~` before checking. If the directory does not exist, warn the user and
170
170
  ask whether to proceed anyway or provide a different path.
171
171
 
172
- After collecting at least one route, write the updated `routing.json`.
172
+ After collecting at least one route, write the updated `config.json`.
173
173
 
174
174
  **Important:** Remind the user to invite the bot to each channel they configured.
175
175
  In Slack, type `/invite @Claude Slack Channel Bots` in each channel (or whatever
@@ -210,7 +210,7 @@ either value:
210
210
 
211
211
  **Validation before writing:**
212
212
 
213
- Before writing `routing.json`, verify that any value supplied for
213
+ Before writing `config.json`, verify that any value supplied for
214
214
  `default_route` or `default_dm_session` exactly matches one of the `cwd`
215
215
  values in `routes`. If a value does not match:
216
216
 
@@ -221,51 +221,86 @@ values in `routes`. If a value does not match:
221
221
 
222
222
  Only write the field once a valid value is confirmed.
223
223
 
224
- Write the final `routing.json` with only the fields the user explicitly set
224
+ Write the final `config.json` with only the fields the user explicitly set
225
225
  (plus the required `routes`). Do not write optional fields the user left at
226
226
  their defaults unless asked.
227
227
 
228
228
  ---
229
229
 
230
- ### Step 5 — Configure custom CLAUDE.md (optional)
230
+ ### Step 5 — Configure custom system prompt for worker sessions
231
231
 
232
- Worker sessions launched by the server can receive a custom system-prompt
233
- append via `append_system_prompt_file` in `routing.json`. This is useful for
234
- giving all workers project-specific instructions: communication rules,
235
- development process, how to spawn sub-workers, and so on.
232
+ Worker sessions launched by the server can receive a custom system prompt
233
+ via `append_system_prompt_file` in `config.json`. This controls how the
234
+ bots behave their role, communication style, and capabilities.
236
235
 
237
- An example template is included with the package. Show the operator where it
238
- lives:
236
+ **This is important.** Without a system prompt, bots won't know to communicate
237
+ via Slack and will try to use the TUI (which nobody can see).
238
+
239
+ An example template is included with the package. Read it for reference:
239
240
 
240
241
  ```bash
241
- ls "$(npm root -g)/claude-slack-channel-bots/skills/EXAMPLE_CLAUDE.md" 2>/dev/null \
242
+ cat "$(npm root -g)/claude-slack-channel-bots/skills/EXAMPLE_CLAUDE.md" 2>/dev/null \
242
243
  || echo "NOT_FOUND"
243
244
  ```
244
245
 
245
- If the file is found, print its path so the operator can inspect it as a
246
- starting point.
246
+ Show the user the example content so they understand what a system prompt
247
+ looks like.
248
+
249
+ Then ask: **"What should your bots do? Describe the role you want them to
250
+ play, how they should communicate, and any specific behaviors."**
247
251
 
248
- Ask the operator: **"Do you want to configure a custom CLAUDE.md file for
249
- worker sessions?"**
252
+ Examples to offer if they're unsure:
253
+ - "I want a coding assistant that responds to my messages in Slack"
254
+ - "I want an orchestrator that manages workers and reports status"
255
+ - "I want a simple bot that answers questions about my codebase"
250
256
 
251
- **If yes:**
257
+ **After the user describes what they want:**
252
258
 
253
- 1. Prompt for the absolute path to their CLAUDE.md file.
254
- 2. Verify the file exists:
259
+ 1. Write a system prompt file based on their description. The file MUST
260
+ always include these two essential sections at the top (adapt the wording
261
+ to match their described role):
262
+
263
+ ```markdown
264
+ # Communication with the User
265
+ The User communicates with you via Slack. Always use the
266
+ mcp__slack-channel-router__reply tool to send messages.
267
+ **Important**: Nothing you send to the TUI will be seen by the User.
268
+ ```
269
+
270
+ Then add sections based on what the user described (role, process,
271
+ capabilities, tone, etc.).
272
+
273
+ 2. Save the file to `~/.claude/channels/slack/system-prompt.md`:
255
274
  ```bash
256
- test -f "<provided-path>" && echo "ok" || echo "not found"
275
+ STATE_DIR="${SLACK_STATE_DIR:-$HOME/.claude/channels/slack}"
257
276
  ```
258
- Expand `~` before checking. If the file does not exist, warn the operator
259
- and re-prompt until a valid path is given or they choose to skip.
260
- 3. Read `routing.json`, add or update the top-level field:
277
+
278
+ 3. Read `config.json`, add or update:
261
279
  ```json
262
- "append_system_prompt_file": "<provided-path>"
280
+ "append_system_prompt_file": "~/.claude/channels/slack/system-prompt.md"
263
281
  ```
264
282
  Write the updated file, preserving all other fields.
265
283
 
266
- **If skipped:**
284
+ 4. Show the user what was written and ask if they want to make changes.
285
+ Iterate until they're happy.
286
+
287
+ **If the user explicitly skips:**
288
+
289
+ Do not write `append_system_prompt_file` to `config.json`, but warn them
290
+ that bots won't know to communicate via Slack without a system prompt.
291
+
292
+ If the project's `CLAUDE.md` already contains all the instructions the bot
293
+ needs (including Slack communication directives), the user can opt out of a
294
+ separate prompt file by setting `system_prompt_mode: "none"` in `config.json`:
295
+
296
+ ```json
297
+ "system_prompt_mode": "none"
298
+ ```
267
299
 
268
- Do not write `append_system_prompt_file` to `routing.json`. Move on.
300
+ With this setting, `append_system_prompt_file` is ignored even if present, and
301
+ only `CLAUDE.md` is used. This is the recommended approach when the bot's
302
+ behavior is fully defined in the project's `CLAUDE.md` and a separate
303
+ `system-prompt.md` would be redundant.
269
304
 
270
305
  ---
271
306
 
@@ -415,7 +450,7 @@ Print a final summary of what was checked and configured:
415
450
 
416
451
  - Environment variables: set / missing
417
452
  - Token format: valid / invalid
418
- - routing.json: populated (N routes) / skeleton
453
+ - config.json: populated (N routes) / skeleton
419
454
  - append_system_prompt_file: configured / skipped
420
455
  - access.json: present / missing
421
456
  - permission-relay.sh hook: present and executable / missing
package/src/cli.ts CHANGED
@@ -105,11 +105,11 @@ export function createCli(deps: CliDeps): CliHandlers {
105
105
  }
106
106
  }
107
107
 
108
- // Check routing.json exists
108
+ // Check config.json exists
109
109
  const stateDir = deps.resolveStateDir()
110
- const routingJson = join(stateDir, 'routing.json')
110
+ const routingJson = join(stateDir, 'config.json')
111
111
  if (!deps.existsSync(routingJson)) {
112
- console.error(`missing prerequisite: routing.json not found at ${routingJson}`)
112
+ console.error(`missing prerequisite: config.json not found at ${routingJson}`)
113
113
  deps.exit(1)
114
114
  }
115
115
 
package/src/config.ts CHANGED
@@ -18,6 +18,7 @@ import { resolve } from 'path'
18
18
 
19
19
  export const MCP_SERVER_NAME = 'slack-channel-router'
20
20
  export const ALLOWED_PRESCRIPTIONS = ['gentle', 'standard', 'aggressive']
21
+ export const ALLOWED_SYSTEM_PROMPT_MODES = ['append', 'none']
21
22
 
22
23
  // ---------------------------------------------------------------------------
23
24
  // Types
@@ -27,7 +28,7 @@ export interface RouteEntry {
27
28
  cwd: string
28
29
  }
29
30
 
30
- /** Raw shape of routing.json as parsed from disk. All optional fields may be absent. */
31
+ /** Raw shape of config.json as parsed from disk. All optional fields may be absent. */
31
32
  export interface RoutingConfigInput {
32
33
  routes: Record<string, RouteEntry>
33
34
  /** CWD path to use when a message arrives on a channel with no explicit entry in routes. */
@@ -43,6 +44,7 @@ export interface RoutingConfigInput {
43
44
  mcp_config_path?: string
44
45
  append_system_prompt_file?: string
45
46
  cozempic_prescription?: string
47
+ system_prompt_mode?: string
46
48
  }
47
49
 
48
50
  /** Validated, fully-resolved routing configuration with all defaults applied. */
@@ -59,6 +61,7 @@ export interface RoutingConfig {
59
61
  mcp_config_path: string
60
62
  append_system_prompt_file?: string
61
63
  cozempic_prescription: string
64
+ system_prompt_mode: string
62
65
  }
63
66
 
64
67
  // ---------------------------------------------------------------------------
@@ -83,6 +86,7 @@ export function applyDefaults(input: RoutingConfigInput): RoutingConfig {
83
86
  mcp_config_path: input.mcp_config_path ?? '~/.claude/slack-mcp.json',
84
87
  append_system_prompt_file: input.append_system_prompt_file,
85
88
  cozempic_prescription: input.cozempic_prescription ?? 'standard',
89
+ system_prompt_mode: input.system_prompt_mode ?? 'append',
86
90
  }
87
91
  }
88
92
 
@@ -163,6 +167,13 @@ export function validateConfig(config: RoutingConfig): void {
163
167
  )
164
168
  }
165
169
 
170
+ // system_prompt_mode must be one of the allowed values
171
+ if (!ALLOWED_SYSTEM_PROMPT_MODES.includes(config.system_prompt_mode)) {
172
+ throw new Error(
173
+ `Routing config validation error: system_prompt_mode "${config.system_prompt_mode}" is invalid. Allowed values are: ${ALLOWED_SYSTEM_PROMPT_MODES.join(', ')}.`,
174
+ )
175
+ }
176
+
166
177
  // default_dm_session must reference an existing route CWD
167
178
  if (config.default_dm_session !== undefined) {
168
179
  if (!seen.has(config.default_dm_session)) {
@@ -213,14 +224,14 @@ export function resolveConfig(input: RoutingConfigInput): RoutingConfig {
213
224
  // I/O wrapper
214
225
  // ---------------------------------------------------------------------------
215
226
 
216
- const DEFAULT_CONFIG_PATH = '~/.claude/channels/slack/routing.json'
227
+ const DEFAULT_CONFIG_PATH = '~/.claude/channels/slack/config.json'
217
228
 
218
229
  /**
219
230
  * Reads routing configuration from disk, parses it, and returns a validated
220
231
  * RoutingConfig. Throws a descriptive error for missing files, malformed JSON,
221
232
  * or validation failures.
222
233
  *
223
- * @param path Path to routing.json. Defaults to ~/.claude/channels/slack/routing.json.
234
+ * @param path Path to config.json. Defaults to ~/.claude/channels/slack/config.json.
224
235
  */
225
236
  export function loadConfig(path?: string): RoutingConfig {
226
237
  const configPath = resolve(expandTilde(path ?? DEFAULT_CONFIG_PATH))
package/src/lib.ts CHANGED
@@ -179,7 +179,7 @@ export interface GateOptions {
179
179
  /** Current bot user ID for mention detection */
180
180
  botUserId: string
181
181
  /**
182
- * Set of channel IDs from routing.json. Any channel in this set is
182
+ * Set of channel IDs from config.json. Any channel in this set is
183
183
  * implicitly opted-in even if it has no entry in access.json's channels map.
184
184
  * access.json entries still take precedence for per-channel overrides.
185
185
  */
@@ -189,8 +189,8 @@ export interface GateOptions {
189
189
  export async function gate(event: unknown, opts: GateOptions): Promise<GateResult> {
190
190
  const ev = event as Record<string, unknown>
191
191
 
192
- // 1. Drop bot messages immediately
193
- if (ev['bot_id']) return { action: 'drop' }
192
+ // 1. Drop our own bot messages (but allow messages from other bots)
193
+ if (ev['bot_id'] && ev['user'] === opts.botUserId) return { action: 'drop' }
194
194
 
195
195
  // 2. Drop non-message subtypes (message_changed, message_deleted, etc.)
196
196
  if (ev['subtype'] && ev['subtype'] !== 'file_share') return { action: 'drop' }
@@ -244,7 +244,7 @@ export async function gate(event: unknown, opts: GateOptions): Promise<GateResul
244
244
  // 5. Channel handling — opt-in per channel ID
245
245
  //
246
246
  // A channel is allowed if it has an explicit entry in access.json OR if it
247
- // appears in routing.json (routeChannels). Channels in routing.json are
247
+ // appears in config.json (routeChannels). Channels in config.json are
248
248
  // implicitly opted-in; access.json entries provide per-channel overrides
249
249
  // (requireMention, allowFrom). If neither applies, drop.
250
250
  const channel = ev['channel'] as string
@@ -3,13 +3,14 @@
3
3
  * postinstall.ts — Scaffold skeleton config files for the Slack Channel Router.
4
4
  *
5
5
  * Creates STATE_DIR and the MCP config parent if missing, then writes
6
- * routing.json, access.json, and slack-mcp.json only when they do not
6
+ * config.json, access.json, and slack-mcp.json only when they do not
7
7
  * already exist. Safe to re-run: existing files are never modified.
8
+ * Migrates routing.json → config.json if the old file is present.
8
9
  *
9
10
  * SPDX-License-Identifier: MIT
10
11
  */
11
12
 
12
- import { existsSync, mkdirSync, writeFileSync, symlinkSync, readlinkSync, unlinkSync } from 'fs'
13
+ import { existsSync, mkdirSync, writeFileSync, symlinkSync, readlinkSync, unlinkSync, renameSync } from 'fs'
13
14
  import { homedir } from 'os'
14
15
  import { dirname, join, resolve } from 'path'
15
16
  import { defaultAccess } from './lib.ts'
@@ -43,13 +44,20 @@ export function runPostinstall(options: PostinstallOptions = {}): void {
43
44
  mkdirSync(stateDir, { recursive: true })
44
45
  mkdirSync(dirname(mcpConfigPath), { recursive: true })
45
46
 
46
- // routing.json
47
- const routingPath = join(stateDir, 'routing.json')
48
- if (existsSync(routingPath)) {
49
- console.log(`skipped: ${routingPath}`)
47
+ // config.json — migrate from routing.json if needed
48
+ const configPath = join(stateDir, 'config.json')
49
+ const legacyPath = join(stateDir, 'routing.json')
50
+ if (existsSync(legacyPath) && !existsSync(configPath)) {
51
+ renameSync(legacyPath, configPath)
52
+ console.log(`Migrated routing.json → config.json`)
53
+ }
54
+
55
+ // Create skeleton config.json if neither old nor new file exists
56
+ if (existsSync(configPath)) {
57
+ console.log(`skipped: ${configPath}`)
50
58
  } else {
51
- writeFileSync(routingPath, JSON.stringify({ routes: {} }, null, 2) + '\n')
52
- console.log(`created: ${routingPath}`)
59
+ writeFileSync(configPath, JSON.stringify({ routes: {} }, null, 2) + '\n')
60
+ console.log(`created: ${configPath}`)
53
61
  }
54
62
 
55
63
  // access.json (permissions 0o600)
package/src/server.ts CHANGED
@@ -40,7 +40,7 @@ import {
40
40
  } from './lib.ts'
41
41
  import { loadConfig, expandTilde, type RoutingConfig, MCP_SERVER_NAME } from './config.ts'
42
42
  import { readSessions, writeSessions, rotateSessions } from './sessions.ts'
43
- import { defaultTmuxClient, sessionName, isClaudeRunning } from './tmux.ts'
43
+ import { defaultTmuxClient, sessionName, isClaudeRunning, getClaudePid } from './tmux.ts'
44
44
  import { startupSessionManager, launchSession } from './session-manager.ts'
45
45
  import { cleanSession, getCozempicAvailable } from './cozempic.ts'
46
46
  import {
@@ -912,7 +912,7 @@ process.on('SIGINT', () => { shutdown('SIGINT').catch(() => process.exit(1)) })
912
912
  //
913
913
  // All Claude Code sessions point to the same URL: http://<host>:<port>/mcp
914
914
  // Route assignment happens after the MCP initialized notification when the
915
- // server calls roots/list and matches the CWD against routing.json.
915
+ // server calls roots/list and matches the CWD against config.json.
916
916
  // ---------------------------------------------------------------------------
917
917
 
918
918
  export async function main(): Promise<void> {
@@ -1003,6 +1003,7 @@ export async function main(): Promise<void> {
1003
1003
  // Already decided — return immediately
1004
1004
  const existingDecision = completedDecisions.get(pollRequestId)
1005
1005
  if (existingDecision !== undefined) {
1006
+ completedDecisions.delete(pollRequestId)
1006
1007
  return new Response(JSON.stringify({ status: 'decided', decision: existingDecision }), {
1007
1008
  status: 200,
1008
1009
  headers: { 'Content-Type': 'application/json' },
@@ -1058,6 +1059,7 @@ export async function main(): Promise<void> {
1058
1059
  })
1059
1060
  }
1060
1061
 
1062
+ completedDecisions.delete(pollRequestId)
1061
1063
  return new Response(JSON.stringify({ status: 'decided', decision }), {
1062
1064
  status: 200,
1063
1065
  headers: { 'Content-Type': 'application/json' },
@@ -1169,6 +1171,7 @@ export async function main(): Promise<void> {
1169
1171
 
1170
1172
  const existingAnswer = completedAnswers.get(pollRequestId)
1171
1173
  if (existingAnswer !== undefined) {
1174
+ completedAnswers.delete(pollRequestId)
1172
1175
  return new Response(JSON.stringify({ status: 'decided', answer: existingAnswer }), {
1173
1176
  status: 200,
1174
1177
  headers: { 'Content-Type': 'application/json' },
@@ -1215,6 +1218,7 @@ export async function main(): Promise<void> {
1215
1218
  })
1216
1219
 
1217
1220
  if (answer !== null) {
1221
+ completedAnswers.delete(pollRequestId)
1218
1222
  return new Response(JSON.stringify({ status: 'decided', answer }), {
1219
1223
  status: 200,
1220
1224
  headers: { 'Content-Type': 'application/json' },
@@ -1319,6 +1323,44 @@ export async function main(): Promise<void> {
1319
1323
  })
1320
1324
  }
1321
1325
 
1326
+ // /is-managed — PID-based session membership check for hook guards
1327
+ if (url.pathname === '/is-managed') {
1328
+ if (req.method !== 'GET') {
1329
+ return new Response('Method Not Allowed', { status: 405 })
1330
+ }
1331
+ // Validate localhost
1332
+ const remoteAddr = server.requestIP(req)
1333
+ const remoteHost = remoteAddr?.address ?? ''
1334
+ if (remoteHost !== '127.0.0.1' && remoteHost !== '::1' && !remoteHost.startsWith('::ffff:127.')) {
1335
+ return new Response('Forbidden', { status: 403 })
1336
+ }
1337
+ const pidStr = url.searchParams.get('pid')
1338
+ const pid = pidStr ? parseInt(pidStr, 10) : NaN
1339
+ if (isNaN(pid) || pid <= 0) {
1340
+ return new Response(JSON.stringify({ error: 'Missing or invalid pid parameter' }), {
1341
+ status: 400,
1342
+ headers: { 'Content-Type': 'application/json' },
1343
+ })
1344
+ }
1345
+ // Check all configured routes
1346
+ if (routingConfig) {
1347
+ for (const [, route] of Object.entries(routingConfig.routes)) {
1348
+ const name = sessionName(route.cwd)
1349
+ const claudePid = await getClaudePid(name, defaultTmuxClient)
1350
+ if (claudePid === pid) {
1351
+ return new Response(JSON.stringify({ managed: true }), {
1352
+ status: 200,
1353
+ headers: { 'Content-Type': 'application/json' },
1354
+ })
1355
+ }
1356
+ }
1357
+ }
1358
+ return new Response(JSON.stringify({ managed: false }), {
1359
+ status: 404,
1360
+ headers: { 'Content-Type': 'application/json' },
1361
+ })
1362
+ }
1363
+
1322
1364
  // Only /mcp is the MCP endpoint — everything else is a 404
1323
1365
  if (url.pathname !== '/mcp') {
1324
1366
  return new Response(
@@ -14,6 +14,7 @@ import { type TmuxClient, sessionName, isClaudeRunning } from './tmux.ts'
14
14
  import { type CleanSessionFn, checkCozempicAvailable, getCozempicAvailable, cleanSession as defaultCleanSession, resolveJsonlPath } from './cozempic.ts'
15
15
  import { type SessionsMap, type SessionRecord } from './sessions.ts'
16
16
  import { type RoutingConfig, MCP_SERVER_NAME } from './config.ts'
17
+ import { isDryRun } from './tokens.ts'
17
18
 
18
19
  // ---------------------------------------------------------------------------
19
20
  // JSONL existence helper
@@ -78,9 +79,13 @@ export async function launchSession(
78
79
  const resumeSessionId = options?.sessionId
79
80
 
80
81
  const escapedConfigPath = routingConfig.mcp_config_path.replace(/'/g, "'\\''")
81
- let baseCmd = `SLACK_CHANNEL_BOT_SESSION=1 claude --mcp-config '${escapedConfigPath}' --dangerously-load-development-channels server:${MCP_SERVER_NAME}`
82
+ // In dry-run mode, skip --dangerously-load-development-channels (requires OAuth which isn't
83
+ // available in Docker/CI). MCP still connects via --mcp-config; only channel routing is lost.
84
+ let baseCmd = isDryRun()
85
+ ? `claude --mcp-config '${escapedConfigPath}'`
86
+ : `claude --mcp-config '${escapedConfigPath}' --dangerously-load-development-channels server:${MCP_SERVER_NAME}`
82
87
 
83
- if (routingConfig.append_system_prompt_file !== undefined) {
88
+ if (routingConfig.system_prompt_mode === 'append' && routingConfig.append_system_prompt_file !== undefined) {
84
89
  try {
85
90
  accessSync(routingConfig.append_system_prompt_file, constants.R_OK)
86
91
  const escapedPromptPath = routingConfig.append_system_prompt_file.replace(/'/g, "'\\''")
@@ -147,6 +152,18 @@ export async function launchSession(
147
152
  sessionId: safeResumeId ?? 'pending',
148
153
  }
149
154
  }
155
+
156
+ // If trust was pre-accepted (e.g. in Docker CI), Claude skips the safety
157
+ // prompt and goes straight to the ready prompt. Detect the Claude Code
158
+ // welcome banner which appears when Claude is fully loaded and ready.
159
+ if (pane.includes('Claude Code v') && pane.includes('❯')) {
160
+ console.error(`[slack] Claude ready (no safety prompt) in session: ${sessionName_}`)
161
+ return {
162
+ tmuxSession: sessionName_,
163
+ lastLaunch: new Date().toISOString(),
164
+ sessionId: safeResumeId ?? 'pending',
165
+ }
166
+ }
150
167
  }
151
168
 
152
169
  return null