@interactive-inc/claude-funnel 0.10.1 → 0.15.2

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,68 +1,78 @@
1
1
  [![npm](https://img.shields.io/npm/v/@interactive-inc/claude-funnel.svg)](https://www.npmjs.com/package/@interactive-inc/claude-funnel)
2
2
  [![license](https://img.shields.io/npm/l/@interactive-inc/claude-funnel.svg)](./LICENSE)
3
3
 
4
- A hub CLI that connects multiple Claude Code agents to external services (Slack / GitHub / Discord) and time-based triggers (cron). External events flow into subscription "channels" and arrive at Claude Code over MCP. Outbound API calls from Claude go back through the same connectors as MCP tools, so replying to a Slack thread or commenting on a GitHub issue does not need a bash subshell.
4
+ A hub for AI coding agents. One long-running daemon owns all external connections; agents subscribe to named channels and react to events without you wiring up shell scripts and cron entries. Outbound replies travel back through the same connectors as MCP tools, so answering a message or commenting on an issue does not need a bash subshell.
5
5
 
6
- The command is `funnel` or its shorthand `fnl`.
6
+ The command is `funnel` (or the shorthand `fnl`).
7
7
 
8
- ## Why funnel
8
+ Connectors today: Slack (Socket Mode), GitHub (poll via `gh`), Discord (Gateway), and cron schedules. Built around Claude Code; the architecture is agent-agnostic.
9
9
 
10
- A single Claude Code session is great at one repo at one moment. The moment you want it to react to things — a Slack mention, a new GitHub issue, a 9 AM standup — you end up gluing shell scripts, cron entries and `bash -c "claude ..."` together, and there is no single place that says "who is listening to what, and who is allowed to reply where."
10
+ ## Why funnel
11
11
 
12
- `funnel` is that place. You configure named subscription boxes (channels), attach connectors to them, launch Claude with a channel binding, and the daemon does the rest:
12
+ A single agent session is great at one repository at one moment. The moment you want it to react to things — a chat mention, a new issue, a 9 AM standup — you end up gluing shell scripts, cron entries, and `bash -c "agent ..."` invocations together. There is no single place that says "who is listening to what, and who is allowed to reply where."
13
13
 
14
- - The gateway daemon owns the external connections. Slack Socket Mode, the Discord Gateway, GitHub polling they connect once, from the daemon, no matter how many Claude sessions you start. Launching a second Claude does not open a second Slack socket; both sessions just subscribe to the same channel and the daemon routes events to them
15
- - Inbound events arrive as MCP notifications, so Claude reacts in the same session it is already running in
16
- - Outbound replies use MCP tools per connector, so they are essentially synchronous (no bash, no CLI cold start)
17
- - Listeners are supervised with health checks and auto-restart, so a flaky Slack connection or a crashed poller recovers on its own
18
- - Multiple Claudes can share the same channel (`fanout`) or compete for events as workers (`exclusive`) — the daemon decides who gets each event
14
+ funnel is that place. Declare named subscription boxes (channels), attach connectors to them, launch the agent with a channel binding, and the daemon handles the rest:
19
15
 
20
- If you have ever wanted "Slack-driven Claude" or "cron-driven Claude" without writing a dispatcher, this is it.
16
+ - The daemon owns the external connections. Each one connects once, no matter how many agent sessions you start. A second agent does not open a second socket; both sessions subscribe to the same channel and the daemon fans events out.
17
+ - Inbound events arrive as MCP notifications, so the agent reacts in the session it is already running in.
18
+ - Outbound replies use MCP tools per connector — essentially synchronous (no bash, no CLI cold start).
19
+ - Listeners are supervised with health checks and automatic restart; a flaky connection or crashed poller recovers on its own.
20
+ - Multiple agents can share a channel (`fanout`) or compete for events as workers (`exclusive`) — the daemon decides who gets each event.
21
21
 
22
22
  ## Concepts
23
23
 
24
24
  ```
25
- External sources Outbound calls
26
- (Slack / GitHub / Discord / cron) (Claude → MCP tools per connector)
27
-
28
-
29
- Channels (with nested per-type connectors)
30
-
31
- ▼ WebSocket
32
- Gateway daemon
33
- (port 9742: WS /ws + listener supervisor + reply API)
34
-
35
- ▼ MCP (stdio)
36
- Claude Code
25
+ external sources outbound replies
26
+ (chat / source-control / cron) (MCP tools per connector)
27
+
28
+
29
+ daemon (port 9742)
30
+ routes events into channels
31
+ serves replies through the same connectors
32
+
33
+ ▼ WebSocket / MCP (stdio)
34
+ agent (subscribes to one channel)
37
35
  ```
38
36
 
39
- Channel a named subscription box. Holds one or more connectors. Each Claude session subscribes to exactly one channel. Delivery mode is `fanout` (every subscriber sees every event; the default) or `exclusive` (round-robin one subscriber per event, for worker pools).
37
+ Three concepts make up the model:
38
+
39
+ Channel — a named subscription box. Holds one or more connectors. An agent session subscribes to exactly one channel. Delivery is `fanout` (every subscriber sees every event, the default) or `exclusive` (one event per subscriber, round-robin — for worker pools).
40
40
 
41
- Connector — a single attachment to an external source. Four types ship: `slack` (Socket Mode push), `gh` (GitHub poll via the `gh` CLI), `discord` (Gateway push), and `schedule` (cron tick). Connectors are nested inside their owning channel.
41
+ Connector — a single attachment from a channel to an external source. Four types ship today: `slack`, `gh`, `discord`, `schedule`. The first three are bidirectional (events in, replies out); `schedule` is one-way (cron ticks in).
42
42
 
43
- Profile — a named launch preset for Claude. Bundles `{ path, sub-agent, channel }` so `fnl claude --profile cto` reproduces a known setup. The first profile in the list is the default.
43
+ Profile — a saved launch preset for an agent. Bundles `{ path, sub-agent, channel }` so `fnl claude --profile cto` reproduces a known setup. The first profile in the list is the default.
44
44
 
45
- Gateway daemon the long-running process and the sole owner of external connections. Each connector connects from here exactly once; Claude sessions never open their own. Hosts the connector listeners with auto-restart, broadcasts events to subscribed clients, and serves the outbound reply API. Runs on port 9742 by default.
45
+ The daemon is where all external connections live. It runs on port 9742, supervises connectors with auto-restart, broadcasts events to subscribed agent sessions over WebSocket, and serves the reply API that MCP calls. Starting or stopping an agent never starts or stops external connections.
46
46
 
47
- MCP the bridge into Claude Code. A thin client: subscribes to one channel over WebSocket (the daemon does the real work) and surfaces one MCP tool per callable connector so Claude can call back out. Starting or stopping a Claude session does not start or stop external connections.
47
+ The MCP layer is a thin bridge into the agent. It subscribes to the bound channel over WebSocket (the daemon does the work) and exposes one tool per callable connector so the agent can reply back out.
48
48
 
49
49
  ## Requirements
50
50
 
51
51
  - [Bun](https://bun.sh) 1.3 or later
52
52
  - [Claude Code](https://docs.claude.com/en/docs/claude-code) CLI
53
- - A Slack / GitHub / Discord token or CLI, depending on which connectors you use
53
+ - A token or CLI for whichever external service you connect (Slack app, `gh` auth, Discord bot, etc.)
54
54
 
55
55
  ## Install
56
56
 
57
+ Per-repo (recommended for getting started — no global install, version pinned by the repo's lock file):
58
+
59
+ ```bash
60
+ bun add -D @interactive-inc/claude-funnel
61
+ bunx funnel claude # or `bunx funnel <any subcommand>`
62
+ ```
63
+
64
+ Global (one CLI for every repo you touch):
65
+
57
66
  ```bash
58
67
  bun add -g @interactive-inc/claude-funnel
68
+ funnel claude # or the `fnl` shorthand
59
69
  ```
60
70
 
61
- The published package already ships the built `dist/`, so `bun add -g` makes `funnel` / `fnl` available immediately — no postinstall step.
71
+ The published package ships the built `dist/`, so either install makes `funnel` / `fnl` available immediately — no post-install step. The rest of the README uses `fnl` for brevity; swap in `bunx funnel` if you went the per-repo route.
62
72
 
63
73
  ## Quick start
64
74
 
65
- Wire Slack to Claude:
75
+ Wire one source to one agent:
66
76
 
67
77
  ```bash
68
78
  fnl channels add ops
@@ -72,16 +82,59 @@ fnl gateway start
72
82
  fnl claude --channel ops
73
83
  ```
74
84
 
75
- From now on every Slack event the bot can see arrives in the running Claude session, and Claude can reply via the `my-slack` MCP tool.
85
+ Every event the connector sees now arrives in the running agent session, and the agent can reply via the `my-slack` MCP tool.
76
86
 
77
87
  Save it as a profile for one-command launches:
78
88
 
79
89
  ```bash
80
90
  fnl profiles add cto --path=/repo/myapp --sub-agent=cto --channel=ops
81
- fnl claude --profile cto # cd /repo/myapp + sub-agent + channel binding
91
+ fnl claude --profile cto # cd + sub-agent + channel binding in one shot
92
+ ```
93
+
94
+ Or drop a `funnel.json` in the repo and `fnl claude` (no args) inside the repo will use it:
95
+
96
+ ```json
97
+ {
98
+ "$schema": "./node_modules/@interactive-inc/claude-funnel/funnel.schema.json",
99
+ "channel": "ops",
100
+ "options": ["--brief", "--agent", "cto"],
101
+ "env": {
102
+ "ANTHROPIC_MODEL": "claude-sonnet-4-6"
103
+ },
104
+ "connectors": [
105
+ {
106
+ "type": "slack",
107
+ "name": "my-slack",
108
+ "env": {
109
+ "botToken": "SLACK_BOT_TOKEN",
110
+ "appToken": "SLACK_APP_TOKEN"
111
+ }
112
+ }
113
+ ]
114
+ }
82
115
  ```
83
116
 
84
- Cron-driven Claude:
117
+ Only `channel` is required.
118
+
119
+ The optional `options` array is prepended to the claude argv on every launch, before any args the user types after `fnl claude`. Use it for repo-wide claude flags (e.g. `--brief`, `--agent <name>`, `--model <name>`). User-supplied CLI args appear later in the argv so they still win on collision.
120
+
121
+ The optional top-level `env` is a `Record<string, string>` of environment variables to layer under the claude process. `process.env` from the launching shell wins on collision, so funnel.json sets defaults that the user can still override one-off via the shell.
122
+
123
+ The optional `connectors` array is treated as the source of truth for the declared channel: missing connectors are created, an existing connector that the spec references by token (not by name) is renamed in place, and connectors not declared in the spec are removed on launch. An absent `connectors` field leaves `~/.funnel` alone.
124
+
125
+ The optional top-level `$schema` points at the JSON Schema so editors can validate and autocomplete the file. The recommended reference for repos with a local install is `./node_modules/@interactive-inc/claude-funnel/funnel.schema.json` — it works without a network round-trip and editors do not need to prompt for trust. The same file is also published at `https://interactive-inc.github.io/open-claude-funnel/funnel.schema.json` (editors usually require explicit trust on first use), and `fnl schema > funnel.schema.json` regenerates a local copy on demand.
126
+
127
+ Each token field resolves in this order:
128
+
129
+ - literal value at the field itself (e.g. `"botToken": "xoxb-..."`) — used as-is
130
+ - env-var name at `env.<field>` (e.g. `"env": { "botToken": "SLACK_BOT_TOKEN" }`) — looked up in `process.env`, falling back to `./.env.local` in the cwd; fails with a clear error when neither is set
131
+ - field omitted everywhere — `fnl claude` prompts on a TTY and writes the answer to `~/.funnel/settings.json`; on non-TTY stdin the launch fails so CI / agent-spawned-agent runs do not hang
132
+
133
+ Setting both a literal and an `env.<field>` for the same field is an error (pick one).
134
+
135
+ `funnel.json` itself is never written to — secrets stay in env vars, `.env.local`, or `~/.funnel`, never in the committed file.
136
+
137
+ Cron-driven agent runs:
85
138
 
86
139
  ```bash
87
140
  fnl channels ops connectors add daily --type=schedule
@@ -91,14 +144,11 @@ fnl channels ops connectors daily schedules add morning \
91
144
 
92
145
  Each tick fires the prompt into the channel. If the daemon was down at 9 AM, the next start catches up the missed slot (`meta.catchup = "true"`) for up to 24 hours.
93
146
 
94
- Multiple Claudes on the same source — pick the delivery mode:
147
+ Multiple agents on the same source — pick the delivery mode:
95
148
 
96
149
  ```bash
97
- # default: fanout — every Claude on the channel sees every event
98
- fnl channels add reviews
99
-
100
- # worker pool — each event is handled by exactly one Claude, round-robin
101
- fnl channels add ingest --delivery=exclusive
150
+ fnl channels add reviews # fanout (default): every agent sees every event
151
+ fnl channels add ingest --delivery=exclusive # exclusive: one event per agent, round-robin
102
152
  ```
103
153
 
104
154
  ## CLI surface
@@ -138,9 +188,11 @@ fnl profiles <name> as-default move to the front of the list
138
188
  fnl profiles rename <old> <new>
139
189
  fnl profiles remove <name>
140
190
 
141
- fnl claude launch the default profile
191
+ fnl claude launch using ./funnel.json, or the default profile
142
192
  fnl claude --profile <name> launch a named profile
143
193
  fnl claude --channel <name> raw launch (no profile, cwd = current dir)
194
+ fnl claude [...] positionals and any flag other than -p / --profile / --channel
195
+ (e.g. --agent, --resume, -c, --model) pass through to claude
144
196
  fnl mcp run as an MCP server (invoked from .mcp.json)
145
197
 
146
198
  fnl gateway status (default subcommand)
@@ -150,6 +202,7 @@ fnl gateway logs [-n <N>] tail diagnostic log
150
202
  fnl gateway listeners live registry (alive / dead)
151
203
 
152
204
  fnl status overall status (channels / profiles / gateway / clients)
205
+ fnl schema print the JSON Schema for funnel.json (pipe to a file for editor support)
153
206
  fnl update `bun i -g @interactive-inc/claude-funnel`
154
207
  fnl (no args) launch the OpenTUI dashboard
155
208
 
@@ -161,7 +214,7 @@ fnl --help every subcommand has --help; verb-w
161
214
 
162
215
  ## Outbound calls (MCP tools per connector)
163
216
 
164
- When `fnl claude` launches Claude Code, the funnel MCP server connects to the gateway and reads the channel's connectors from `~/.funnel/settings.json`. For every callable connector (`slack` / `discord` / `gh`; `schedule` is one-way and skipped), the MCP advertises one tool with the connector's name. Claude calls them like:
217
+ When `fnl claude` launches the agent, the funnel MCP server connects to the daemon and reads the channel's connectors from `~/.funnel/settings.json`. For every callable connector (`slack` / `discord` / `gh`; `schedule` is one-way and skipped), MCP advertises one tool with the connector's name. The agent calls them like:
165
218
 
166
219
  ```jsonc
167
220
  // MCP: tools/list returns
@@ -169,7 +222,7 @@ When `fnl claude` launches Claude Code, the funnel MCP server connects to the ga
169
222
  { "name": "ops-slack", "inputSchema": { ... } }
170
223
  { "name": "gh-main", "inputSchema": { ... } }
171
224
 
172
- // Claude calls
225
+ // agent calls
173
226
  tools/call name="discord" arguments={
174
227
  "method": "POST",
175
228
  "path": "/channels/123/messages",
@@ -177,9 +230,9 @@ tools/call name="discord" arguments={
177
230
  }
178
231
  ```
179
232
 
180
- The MCP forwards via HTTP `POST /channels/<channel>/connectors/<connector>/call` to the gateway daemon, which dispatches through the existing `FunnelChannels.call()` adapter. No bash subshell, no CLI cold start — replies are essentially synchronous.
233
+ MCP forwards via HTTP `POST /channels/<channel>/connectors/<connector>/call` to the daemon, which dispatches through the connector's adapter. No bash subshell, no CLI cold start — replies are essentially synchronous.
181
234
 
182
- If you need to invoke a connector from outside Claude, the same path is reachable as `fnl channels <ch> connectors <c> request --method=<...> [--key=value ...]`.
235
+ To invoke a connector from outside an agent, the same path is reachable as `fnl channels <ch> connectors <c> request --method=<...> [--key=value ...]`.
183
236
 
184
237
  ## Data model
185
238
 
@@ -189,7 +242,7 @@ Channel = { id, name, delivery, connectors[] }
189
242
  or `exclusive` (round-robin one client per event)
190
243
 
191
244
  Connector =
192
- | { type: "slack", name, botToken, appToken } Slack Socket Mode
245
+ | { type: "slack", name, botToken, appToken } Slack Socket Mode
193
246
  | { type: "gh", name, pollInterval? } GitHub (gh CLI, poll-based)
194
247
  | { type: "discord", name, botToken } Discord Gateway
195
248
  | { type: "schedule", name, entries[] } cron-driven; entries = { id, cron, prompt, enabled?, catchupPolicy? }
@@ -197,6 +250,13 @@ Connector =
197
250
  Profile = { name, path, subAgent, channelId }
198
251
  named launch preset; the first profile in the list is the default
199
252
 
253
+ LocalConfig = { channel, options?, env?, connectors? }
254
+ per-repo file (funnel.json) checked by `fnl claude` when no --profile / --channel is given
255
+ options[] is prepended to claude argv (user CLI args override); env merges under process.env;
256
+ connectors[] declares connectors to materialize on launch (each token field accepts a literal,
257
+ an env-var reference at `env.<field>` resolved from process.env and ./.env.local, or omission
258
+ for a TTY prompt persisted to ~/.funnel)
259
+
200
260
  Settings = { channels[], profiles[] } → ~/.funnel/settings.json
201
261
  ```
202
262
 
@@ -208,7 +268,7 @@ Persistent state lives under `~/.funnel/`. Volatile logs and the event store liv
208
268
  ~/.funnel/
209
269
  ├── settings.json channels[] with nested connectors, profiles[]
210
270
  ├── gateway.pid daemon PID
211
- ├── gateway.token Bearer token for gateway HTTP / WS
271
+ ├── gateway.token Bearer token for daemon HTTP / WS
212
272
  ├── claude/
213
273
  │ └── <profile>.pid prevents double-launch of the same profile
214
274
  └── channels/
@@ -219,23 +279,23 @@ Persistent state lives under `~/.funnel/`. Volatile logs and the event store liv
219
279
 
220
280
  /tmp/funnel/
221
281
  ├── events/events.db SQLite event store with replay-by-seq
222
- ├── funnel.log diagnostic log (gateway lifecycle, listener boot, connects)
282
+ ├── funnel.log diagnostic log (daemon lifecycle, listener boot, connects)
223
283
  └── gateway.log daemon stdout/stderr
224
284
  ```
225
285
 
226
- Notes
286
+ Notes:
227
287
 
228
288
  - Connector configuration is stored inline in `settings.json` (nested under the channel), not in a per-type directory. Per-connector durable state (e.g. `lastFiredAt` for schedule catch-up) lives under `channels/<channel-id>/connectors/<connector-id>/state.json` keyed by id, so renames do not lose state.
229
- - `funnel gateway logs` tails `funnel.log` and renders it as YAML.
289
+ - `fnl gateway logs` tails `funnel.log` and renders it as YAML.
230
290
 
231
291
  ## Environment variables
232
292
 
233
293
  | Variable | Purpose |
234
294
  | ---------------------- | --------------------------------------------------------------------------------------------- |
235
295
  | `FUNNEL_CHANNEL_ID` | Injected into the child process by `fnl claude`; the funnel MCP uses it to subscribe. |
236
- | `FUNNEL_PORT` | Gateway port (default 9742). |
237
- | `FUNNEL_GATEWAY_URL` | Gateway base URL used by MCP for both WS subscribe and HTTP reply (default `http://localhost:9742`). |
238
- | `FUNNEL_GATEWAY_TOKEN` | Bearer token for the gateway HTTP / WS. Defaults to the contents of `~/.funnel/gateway.token`. |
296
+ | `FUNNEL_PORT` | Daemon port (default 9742). |
297
+ | `FUNNEL_GATEWAY_URL` | Daemon base URL used by MCP for both WS subscribe and HTTP reply (default `http://localhost:9742`). |
298
+ | `FUNNEL_GATEWAY_TOKEN` | Bearer token for the daemon HTTP / WS. Defaults to the contents of `~/.funnel/gateway.token`. |
239
299
 
240
300
  ## Discord bot setup
241
301
 
@@ -293,7 +353,7 @@ await server.stop()
293
353
  unsubscribe()
294
354
  ```
295
355
 
296
- The gateway daemon exposes `/health`, `/status`, `/listeners*`, `/channels/:channel/connectors/:connector/call`, plus the `/ws?channel=<name>` WebSocket.
356
+ The daemon exposes `/health`, `/status`, `/listeners*`, `/channels/:channel/connectors/:connector/call`, plus the `/ws?channel=<name>` WebSocket.
297
357
 
298
358
  ### Sandboxed Funnel
299
359
 
@@ -381,9 +441,9 @@ The published package ships a bundled library entry (`dist/index.js`) plus gener
381
441
 
382
442
  This repo ships a Claude Code skill at `.claude/skills/funnel/SKILL.md`. It briefs Claude on the architecture and command groups, and tells it to defer flag-level details to `funnel <command> --help`.
383
443
 
384
- Project-scoped (auto). If you run `claude` inside this repo, the skill is picked up automatically — no install step.
444
+ Project-scoped (auto): if you run `claude` inside this repo, the skill is picked up automatically — no install step.
385
445
 
386
- Global (use the skill in any project). Claude Code does not currently provide a CLI to install skills from a remote URL, so copy the file into your personal skills directory:
446
+ Global (use the skill in any project): Claude Code does not currently provide a CLI to install skills from a remote URL, so copy the file into your personal skills directory:
387
447
 
388
448
  ```bash
389
449
  # from a clone of this repo