@a5c-ai/channels-adapter 5.1.1-staging.0007199a1cb2

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 (71) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +661 -0
  3. package/dist/backend.d.ts +7 -0
  4. package/dist/backend.d.ts.map +1 -0
  5. package/dist/backend.js +22 -0
  6. package/dist/backend.js.map +1 -0
  7. package/dist/backends/github.d.ts +16 -0
  8. package/dist/backends/github.d.ts.map +1 -0
  9. package/dist/backends/github.js +357 -0
  10. package/dist/backends/github.js.map +1 -0
  11. package/dist/backends/jira.d.ts +18 -0
  12. package/dist/backends/jira.d.ts.map +1 -0
  13. package/dist/backends/jira.js +256 -0
  14. package/dist/backends/jira.js.map +1 -0
  15. package/dist/backends/webhook.d.ts +25 -0
  16. package/dist/backends/webhook.d.ts.map +1 -0
  17. package/dist/backends/webhook.js +206 -0
  18. package/dist/backends/webhook.js.map +1 -0
  19. package/dist/cli.d.ts +2 -0
  20. package/dist/cli.d.ts.map +1 -0
  21. package/dist/cli.js +28 -0
  22. package/dist/cli.js.map +1 -0
  23. package/dist/config.d.ts +10 -0
  24. package/dist/config.d.ts.map +1 -0
  25. package/dist/config.js +323 -0
  26. package/dist/config.js.map +1 -0
  27. package/dist/dedup.d.ts +26 -0
  28. package/dist/dedup.d.ts.map +1 -0
  29. package/dist/dedup.js +68 -0
  30. package/dist/dedup.js.map +1 -0
  31. package/dist/filter.d.ts +9 -0
  32. package/dist/filter.d.ts.map +1 -0
  33. package/dist/filter.js +115 -0
  34. package/dist/filter.js.map +1 -0
  35. package/dist/index.d.ts +14 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +21 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/poller.d.ts +63 -0
  40. package/dist/poller.d.ts.map +1 -0
  41. package/dist/poller.js +195 -0
  42. package/dist/poller.js.map +1 -0
  43. package/dist/registry.d.ts +22 -0
  44. package/dist/registry.d.ts.map +1 -0
  45. package/dist/registry.js +59 -0
  46. package/dist/registry.js.map +1 -0
  47. package/dist/relay.d.ts +58 -0
  48. package/dist/relay.d.ts.map +1 -0
  49. package/dist/relay.js +172 -0
  50. package/dist/relay.js.map +1 -0
  51. package/dist/runtime.d.ts +27 -0
  52. package/dist/runtime.d.ts.map +1 -0
  53. package/dist/runtime.js +198 -0
  54. package/dist/runtime.js.map +1 -0
  55. package/dist/server.d.ts +64 -0
  56. package/dist/server.d.ts.map +1 -0
  57. package/dist/server.js +217 -0
  58. package/dist/server.js.map +1 -0
  59. package/dist/spawner.d.ts +96 -0
  60. package/dist/spawner.d.ts.map +1 -0
  61. package/dist/spawner.js +368 -0
  62. package/dist/spawner.js.map +1 -0
  63. package/dist/state.d.ts +43 -0
  64. package/dist/state.d.ts.map +1 -0
  65. package/dist/state.js +92 -0
  66. package/dist/state.js.map +1 -0
  67. package/dist/types.d.ts +140 -0
  68. package/dist/types.d.ts.map +1 -0
  69. package/dist/types.js +10 -0
  70. package/dist/types.js.map +1 -0
  71. package/package.json +78 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 mcp-channels contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,661 @@
1
+ # @a5c-ai/channels-adapter
2
+
3
+ > An MCP-server mini-framework that turns external systems (GitHub, Jira, inbound
4
+ > webhooks, …) into a Claude Code **channel** via a declarative YAML config — with
5
+ > pluggable backends, per-source polling + change detection + dedup, declarative
6
+ > filtering, a `reply` tool that relays Claude's replies back to the origin, and an
7
+ > optional per-event session spawner backed by `@a5c-ai/adapters`.
8
+
9
+ `@a5c-ai/channels-adapter` is a TypeScript (ESM) stdio MCP server. It:
10
+
11
+ - declares the experimental `claude/channel` capability,
12
+ - **polls** pluggable backends on a per-source schedule,
13
+ - computes **what changed since the last check** (a per-source *cursor*) and
14
+ **dedupes** so each external event triggers Claude **at most once**,
15
+ - **filters** events declaratively (assignee, label, project, substring/regex,
16
+ boolean `all`/`any`/`not`),
17
+ - pushes each surviving event into the session as a `notifications/claude/channel`
18
+ event (Claude sees `<channel source="…" …>content</channel>`),
19
+ - **relays Claude's replies back to origin** (a comment on the GitHub issue/PR or
20
+ Jira issue that triggered the event) through a single `reply` MCP tool, and
21
+ - optionally **spawns a fresh agent session per event** via
22
+ [`@a5c-ai/adapters`](https://www.npmjs.com/package/@a5c-ai/adapters), handing the
23
+ spawned session the same `reply_to` token so it can answer the same origin.
24
+
25
+ Backends are **hook points** — a tiny documented interface (`poll`, `reply`,
26
+ optional `validateConfig`/`init`) anyone can implement to add a new system without
27
+ touching the core. Three built-ins ship: `github`, `jira`, and `webhook`.
28
+
29
+ > Design & spec: see [docs/DESIGN.md](docs/DESIGN.md), [docs/SPEC.md](docs/SPEC.md).
30
+
31
+ ---
32
+
33
+ ## Table of contents
34
+
35
+ - [Install](#install)
36
+ - [Quick start](#quick-start)
37
+ - [YAML configuration schema](#yaml-configuration-schema)
38
+ - [Built-in backends](#built-in-backends)
39
+ - [The Backend hook interface](#the-backend-hook-interface)
40
+ - [Writing a custom backend](#writing-a-custom-backend)
41
+ - [How polling, change-detection & dedup work](#how-polling-change-detection--dedup-work)
42
+ - [The opaque `reply_to` token](#the-opaque-reply_to-token)
43
+ - [Event-triggered session spawning](#event-triggered-session-spawning)
44
+ - [Wiring to a real Claude Code session](#wiring-to-a-real-claude-code-session)
45
+ - [Programmatic API](#programmatic-api)
46
+ - [Testing](#testing)
47
+ - [License](#license)
48
+
49
+ ---
50
+
51
+ ## Install
52
+
53
+ Requires **Node.js ≥ 20.9** (developed/tested on Node 22).
54
+
55
+ ```bash
56
+ npm i @a5c-ai/channels-adapter
57
+ ```
58
+
59
+ The package is published with a build step (TypeScript → `dist/`); installing from
60
+ npm gives you the compiled `dist/`. It exposes two equivalent bin names:
61
+
62
+ - **`adapters-channels`** — the canonical bin (matches the in-repo adapters family).
63
+ - **`mcp-channels`** — a back-compat alias for the original framework name.
64
+
65
+ Both run the same stdio MCP server.
66
+
67
+ ---
68
+
69
+ ## Quick start
70
+
71
+ 1. Write a config (see [the schema](#yaml-configuration-schema)). A ready example
72
+ lives at [`examples/channels.yml`](examples/channels.yml).
73
+
74
+ 2. Export the env vars your config interpolates:
75
+
76
+ ```bash
77
+ export GITHUB_TOKEN=ghp_xxx
78
+ export JIRA_EMAIL=you@example.com
79
+ export JIRA_TOKEN=xxx
80
+ ```
81
+
82
+ 3. Run the stdio server:
83
+
84
+ ```bash
85
+ npx -p @a5c-ai/channels-adapter adapters-channels examples/channels.yml
86
+ # or, with the alias: npx -p @a5c-ai/channels-adapter mcp-channels examples/channels.yml
87
+ # or, against a checkout: node dist/cli.js examples/channels.yml
88
+ ```
89
+
90
+ The process speaks MCP over stdio. To actually attach it to Claude Code, see
91
+ [Wiring to a real Claude Code session](#wiring-to-a-real-claude-code-session).
92
+
93
+ ---
94
+
95
+ ## YAML configuration schema
96
+
97
+ ```yaml
98
+ server:
99
+ name: mcp-channels # MCP server name → <channel source="mcp-channels">
100
+ instructions: "...optional override..." # else a sane default is used
101
+ permissionRelay: false # opt-in claude/channel/permission relay
102
+ replySecret: "${MCP_CHANNELS_REPLY_SECRET}" # optional; stable HMAC secret (see spawning)
103
+
104
+ state:
105
+ dir: ./.mcp-channels-state # optional; default ~/.claude/channels/<name>/state
106
+ maxSeenPerSource: 1000 # FIFO-bounded per-source dedup seen-set
107
+
108
+ defaults:
109
+ pollIntervalSeconds: 60 # per-source override falls back to this
110
+
111
+ sources:
112
+ - id: gh-comments-by-alice # unique id; also the state-file key
113
+ backend: github # a built-in type (github|jira|webhook) OR ./relative/custom.js
114
+ pollIntervalSeconds: 30
115
+ auth: { token: "${GITHUB_TOKEN}" }
116
+ config: { repo: "octo/app", events: [issue_comment] }
117
+ filter:
118
+ all:
119
+ - { field: "issue.assignee.login", op: eq, value: "alice" }
120
+ routing: { reply: comment } # default reply mode per backend
121
+
122
+ - id: jira-crash-bugs
123
+ backend: jira
124
+ auth:
125
+ baseUrl: "https://x.atlassian.net"
126
+ email: "${JIRA_EMAIL}"
127
+ token: "${JIRA_TOKEN}"
128
+ config: { project: "BUG", events: [issue_created] }
129
+ filter:
130
+ all:
131
+ - { field: "fields.labels", op: includes, value: "needs-triage" }
132
+ - { field: "fields.summary", op: contains, value: "crash", ignoreCase: true }
133
+ ```
134
+
135
+ ### `${ENV}` interpolation
136
+
137
+ `${NAME}` placeholders are read from `process.env` at load time. A **missing**
138
+ required variable is a **validation error** — never a crash at poll time.
139
+
140
+ ### Validation
141
+
142
+ `loadConfig()` never throws: malformed YAML, an unresolved `${ENV}`, an unknown
143
+ backend type, a malformed filter, or a backend's own `validateConfig()` failure
144
+ are all collected into an `errors: string[]` array. `createRuntime()` throws once,
145
+ up front, with all the aggregated messages if the config is invalid — including
146
+ errors from the **built-in** github/jira/webhook backends (e.g. a github
147
+ `config.repo` that is not `"owner/name"`, a jira source missing `auth.token`, or a
148
+ webhook source with an unrecognized `config.backend`). Custom-path backends are
149
+ imported and validated **at startup**, so a broken custom backend (missing
150
+ `poll`/`reply`) fails `createRuntime`, not the first tick.
151
+
152
+ ### The filter engine
153
+
154
+ A filter is either a **leaf** clause `{ field, op, value, ignoreCase? }` over a
155
+ **dot-path** into the event payload, or a **combinator** `{ all: [...] }`,
156
+ `{ any: [...] }`, or `{ not: clause }`.
157
+
158
+ | op | meaning |
159
+ |----|---------|
160
+ | `eq` / `ne` | strict equal / not-equal |
161
+ | `in` / `nin` | field value is / is not in a list |
162
+ | `includes` | the field is an **array** containing `value` |
163
+ | `contains` | the field is a **string** containing substring `value` |
164
+ | `regex` | the field is a **string** matching the `value` pattern |
165
+ | `exists` | path is present (`value:true`) / absent (`value:false`) |
166
+ | `gt` `gte` `lt` `lte` | numeric comparisons |
167
+
168
+ `contains` and `regex` honor `ignoreCase: true`. An **unknown op, a bad/missing
169
+ dot-path, or a malformed regex yields no match and never throws** — a
170
+ misconfigured filter can't crash a poll. An empty/missing filter matches
171
+ everything.
172
+
173
+ ---
174
+
175
+ ## Built-in backends
176
+
177
+ ### `github`
178
+
179
+ - **Auth:** `auth.token` (a PAT). Base `https://api.github.com` (override with
180
+ `config.baseUrl` for GitHub Enterprise).
181
+ - **Config:** `repo: "owner/name"`, `events: [issue_comment | issue_opened | pr_opened]`.
182
+ - **`issue_comment`:** `GET /repos/{o}/{r}/issues/comments?sort=updated&direction=asc&since=<cursor>`.
183
+ The cursor is the max `updated_at`. Each comment resolves its parent issue/PR
184
+ (one cached GET) so filters like `issue.assignee.login` work. Dedup id is
185
+ `gh:comment:<id>` (an edit does **not** re-trigger; set
186
+ `config.retriggerOnEdit: true` to make `<id>:<updated_at>` the id).
187
+ - **`issue_opened`:** `GET /repos/{o}/{r}/issues?state=open&sort=created&direction=asc&since=<cursor>`,
188
+ post-filtered to `created_at >= cursor` (+ seen-set dedup, so equal-timestamp
189
+ creations aren't dropped). Dedup id `gh:issue:<id>`.
190
+ - **Pagination:** follows the `Link: rel="next"` header and accumulates **all**
191
+ pages before advancing the cursor.
192
+ - **Failure handling:** a non-2xx list response advances **nothing** (cursor and
193
+ seen are kept) so the window is retried; a failed parent-issue fetch holds the
194
+ comment for the next poll instead of dropping it.
195
+ - **meta:** `{ repo, issue_number, kind, author, reply_to }`.
196
+ - **reply:** `POST /repos/{o}/{r}/issues/{issue_number}/comments { body: text }`.
197
+
198
+ ### `jira`
199
+
200
+ - **Auth:** `auth.baseUrl`, `auth.email`, `auth.token` (HTTP Basic).
201
+ - **Config:** `project` (`[A-Za-z0-9_]+`), `events: [issue_created | issue_updated]`,
202
+ optional `config.jql` extra clause.
203
+ - **Poll:** `POST /rest/api/3/search` with JQL
204
+ `project = "<P>" AND created >= "<cursor-minute>" ORDER BY created ASC`
205
+ (`updated` for `issue_updated`). The cursor is stored at full precision but the
206
+ JQL literal is the minute-granular `"yyyy-MM-dd HH:mm"` Jira accepts.
207
+ - **Pagination:** loops `startAt` until `startAt + len >= total`, accumulating all
208
+ pages before advancing the cursor.
209
+ - **Dedup:** because JQL datetime comparisons are minute-granular, the cursor is
210
+ combined with a seen-set keyed by `jira:<key>:<created>` (full timestamp) so a
211
+ same-minute issue re-returned next poll is recognized and dropped.
212
+ - **Security:** `config.project` is validated against `[A-Za-z0-9_]+` and any
213
+ `config.jql` is stripped of quote/backslash/semicolon so neither can inject into
214
+ the JQL string.
215
+ - **meta:** `{ project, issue_key, kind, reply_to }`.
216
+ - **reply:** `POST /rest/api/3/issue/{key}/comment` with an ADF body.
217
+
218
+ ### `webhook` (optional, additive)
219
+
220
+ An **additive** built-in backend (it does not replace or alter `github`/`jira`)
221
+ that turns **inbound webhook payloads** (GitHub / GitLab / Bitbucket / generic)
222
+ into channel events by delegating the parsing to
223
+ [`@a5c-ai/triggers-adapter`](https://www.npmjs.com/package/@a5c-ai/triggers-adapter)'s
224
+ `normalizeEvent(backend, eventName, payload)` — the same normalizer the
225
+ triggers-adapter uses for CI/action triggers — so the channels framework reuses
226
+ that battle-tested webhook-shape knowledge instead of re-deriving it.
227
+
228
+ Channels is **poll-based**, so this backend reads a **queue of already-captured
229
+ webhook payloads** and normalizes each on every poll (live HTTP receipt — a bound
230
+ listener / tunnel — is intentionally out of scope, mirroring how live agent launch
231
+ is out of offline-test scope):
232
+
233
+ - **Auth:** none (webhooks are inbound; nothing is posted back).
234
+ - **Config:**
235
+ - `backend: github | gitlab | bitbucket | generic-webhook` — which webhook shape
236
+ `normalizeEvent` should parse (unrecognized values are a validation error).
237
+ - `payloads: [...]` — an inline array of captured webhook entries (the injection
238
+ seam: each entry is `{ eventName, payload, id? }`, or a bare payload object
239
+ whose `eventName` falls back to `config.eventName`).
240
+ - `dir: "./captured"` — a directory of captured `*.json` payload files (read
241
+ through an injectable `config.fs`, defaulting to `node:fs/promises`; files are
242
+ sorted by name for stable emit order; an unparseable file is skipped with a log
243
+ line, not a poll failure).
244
+ - `eventName: "..."` — optional fallback event name for bare-payload entries.
245
+ - At least one of `payloads` / `dir` is **required**.
246
+ - **Dedup id:** the entry's explicit `id` (e.g. a GitHub `X-GitHub-Delivery`) when
247
+ present, else a deterministic id derived from the normalized shape
248
+ (`webhook:<backend>:<event>:<action>:<sha|url|repo#ref>`).
249
+ - **meta:** `{ backend, event, action?, repo?, author?, ref?, sha?, source_branch?,
250
+ target_branch?, url? }` (empties dropped; values stringified).
251
+ - **payload:** the raw upstream payload (so declarative dot-path filters match).
252
+ - **reply:** **unsupported** — a webhook is a one-way inbound notification with no
253
+ generic callback channel, so `reply()` throws a clear, actionable error rather
254
+ than silently no-op'ing. To reply, route through the concrete `github`/`jira`
255
+ backend the payload originated from.
256
+
257
+ ---
258
+
259
+ ## The Backend hook interface
260
+
261
+ A backend is a module that default-exports an object implementing the `Backend`
262
+ interface (TypeScript types ship in `dist/index.d.ts`):
263
+
264
+ ```ts
265
+ interface ChannelEvent {
266
+ id: string;
267
+ content: string;
268
+ meta: Record<string, string>;
269
+ payload: object;
270
+ routing: object;
271
+ }
272
+ interface PollContext { source: object; state: object; http: Function; log: Function; now: Date }
273
+ interface PollResult { events: ChannelEvent[]; state: object }
274
+ interface Backend {
275
+ type: string;
276
+ validateConfig?(source: object): string[];
277
+ init?(source: object): void | Promise<void>;
278
+ poll(ctx: PollContext): Promise<PollResult>;
279
+ reply(a: { routing: object; text: string; source: object; http: Function }):
280
+ Promise<{ ok: boolean; ref?: string }>;
281
+ }
282
+ ```
283
+
284
+ - **`type`** — stable identifier (used in logs and as the registry key).
285
+ - **`validateConfig(source) → string[]`** *(optional)* — runs at config-load time;
286
+ return human-readable problems (empty == valid). This is how misconfiguration
287
+ becomes a validation error instead of a poll-time crash.
288
+ - **`init(source)`** *(optional)* — runs once before the first poll of a source.
289
+ - **`poll(ctx) → { events, state }`** — the heart of a backend. It MUST be pure
290
+ w.r.t. side effects **except HTTP via the injected `ctx.http`** (so tests inject
291
+ a fake), MUST use `ctx.state.cursor` to request only changes since last check
292
+ where the API supports it, and MUST set `routing` on **every** event so a reply
293
+ can reach origin. It returns the new events **and** the next state to persist.
294
+ - **`reply({ routing, text, source, http }) → { ok, ref? }`** — posts `text` back
295
+ to the origin identified by `routing`, using `source.auth` and the injected
296
+ `http`. A falsy `ok` or a thrown error becomes a tool result with `isError:true`.
297
+
298
+ > The **core** (not the backend) is the authoritative filter + dedup gate. A
299
+ > backend MAY pre-filter at the API for efficiency but MUST NOT rely on it for
300
+ > correctness.
301
+
302
+ `defineBackend(obj)` is an identity helper that gives editors the `Backend` type
303
+ and asserts the required `poll`/`reply` hooks are present.
304
+
305
+ ---
306
+
307
+ ## Writing a custom backend
308
+
309
+ Reference a custom module from YAML by **relative path** instead of a built-in
310
+ type. The path resolves relative to the config file:
311
+
312
+ ```yaml
313
+ sources:
314
+ - id: my-thing
315
+ backend: ./examples/custom-backend.js # resolved by registry.load()
316
+ pollIntervalSeconds: 30
317
+ auth: { token: "${MY_TOKEN}" }
318
+ config: { endpoint: "https://example.test/api/events" }
319
+ filter:
320
+ all:
321
+ - { field: "kind", op: eq, value: "mention" }
322
+ ```
323
+
324
+ A minimal backend (importing the authoring helper from the package):
325
+
326
+ ```js
327
+ import { defineBackend } from '@a5c-ai/channels-adapter';
328
+
329
+ export default defineBackend({
330
+ type: 'example-custom',
331
+
332
+ validateConfig(source) {
333
+ const errors = [];
334
+ if (!source?.config?.endpoint) errors.push('config.endpoint is required');
335
+ if (!source?.auth?.token) errors.push('auth.token is required');
336
+ return errors;
337
+ },
338
+
339
+ async poll(ctx) {
340
+ const { source, state, http } = ctx;
341
+ const cursor = state?.cursor ?? null;
342
+ const seen = state?.seen ?? [];
343
+
344
+ const url = new URL(source.config.endpoint);
345
+ if (cursor) url.searchParams.set('since', cursor); // ask for "since last time"
346
+
347
+ const res = await http(url.toString(), {
348
+ headers: { authorization: `Bearer ${source.auth.token}` }
349
+ });
350
+ const items = Array.isArray(res?.body?.items) ? res.body.items : [];
351
+
352
+ const seenSet = new Set(seen);
353
+ const fresh = items.filter((it) => !seenSet.has(String(it.id)));
354
+
355
+ const events = fresh.map((it) => ({
356
+ id: `example:${it.id}`, // stable dedup id
357
+ content: it.text ?? '',
358
+ meta: { kind: String(it.kind), author: String(it.author) },
359
+ payload: it, // raw object for dot-path filters
360
+ routing: { endpoint: source.config.endpoint, itemId: it.id } // reply needs this
361
+ }));
362
+
363
+ const nextCursor = fresh.reduce((a, it) => (it.updatedAt > a ? it.updatedAt : a), cursor ?? '');
364
+ return { events, state: { cursor: nextCursor, seen: [...seen, ...fresh.map((it) => String(it.id))] } };
365
+ },
366
+
367
+ async reply({ routing, text, source, http }) {
368
+ const res = await http(`${routing.endpoint}/${routing.itemId}/replies`, {
369
+ method: 'POST',
370
+ headers: { authorization: `Bearer ${source.auth.token}`, 'content-type': 'application/json' },
371
+ body: JSON.stringify({ text })
372
+ });
373
+ return { ok: res?.status >= 200 && res?.status < 300, ref: res?.body?.id };
374
+ }
375
+ });
376
+ ```
377
+
378
+ A complete, commented version lives at
379
+ [`examples/custom-backend.js`](examples/custom-backend.js). You can also register
380
+ a backend programmatically: `registry.register('my-type', backend)`.
381
+
382
+ ---
383
+
384
+ ## How polling, change-detection & dedup work
385
+
386
+ State per source is `{ cursor, seen[] }`, persisted as JSON (atomic write) under
387
+ `state.dir` (default `~/.claude/channels/<name>/state`). `seen` is **bounded**
388
+ (`maxSeenPerSource`, FIFO).
389
+
390
+ On each tick for a source (`Poller.tick(id)`):
391
+
392
+ 1. **load state** → `{ cursor, seen }`.
393
+ 2. **poll the backend** with `{ source, state, http, log, now }`. The backend
394
+ queries the upstream API narrowed by `cursor`, paginating to fetch the whole
395
+ window, and returns new events + the next state.
396
+ 3. **filter** (core): keep events whose `payload` satisfies `source.filter`.
397
+ 4. **dedup** (core): drop events whose id is already in `seen`. This is the
398
+ authoritative *at-most-once* gate, even when polling windows overlap (an
399
+ inclusive `since`, a minute-granular JQL `>=`, …).
400
+ 5. **mint `reply_to`** and attach it to each survivor's `meta`.
401
+ 6. **persist state**: the cursor advances; `seen` grows (boundary-safe FIFO).
402
+ 7. **dispatch** each survivor per the source's `onEvent` mode (`emit` / `spawn` /
403
+ `both`, default `emit`) — `emit` sends one `notifications/claude/channel`.
404
+
405
+ Two correctness details worth knowing:
406
+
407
+ - **Boundary-safe pruning.** The FIFO bound never evicts an id whose timestamp is
408
+ still inside the cursor window (the "boundary bucket"). A naive count-based FIFO
409
+ could drop such an id and then re-emit it on the next overlapping poll; the
410
+ bound is *soft* and retains the boundary bucket so that can't happen.
411
+ - **Serialized ticks.** Ticks for the **same** source are serialized — a second
412
+ tick that arrives while the first is running is queued behind it, so two
413
+ overlapping ticks can't read the same prior state and clobber each other's
414
+ cursor/seen. Ticks for **different** sources run concurrently.
415
+
416
+ ---
417
+
418
+ ## The opaque `reply_to` token
419
+
420
+ A reply must reach the **exact** origin, but Claude only echoes one small
421
+ attribute back, and inbound channel text is an untrusted prompt-injection surface.
422
+ So the framework mints a single **opaque, tamper-evident** routing token per event
423
+ and exposes it as `meta.reply_to`. Claude treats it as a black box and passes it
424
+ verbatim to the `reply` tool.
425
+
426
+ - The token is `<base64url(JSON)>.<base64url(HMAC-SHA256)>`, URL-safe and a valid
427
+ channel attribute value.
428
+ - By default it is signed with a **per-process random secret** generated at
429
+ startup, so a forged or tampered token (even a single flipped character, or a
430
+ hand-rolled `base64url(JSON)` without the signature) fails verification.
431
+ - It carries **no upstream credentials** — auth lives only in the loaded config,
432
+ keyed by source id. The HMAC exists so the runtime never POSTs under real
433
+ credentials to a routing target it didn't itself mint.
434
+
435
+ Decoding a bad/garbled/forged token returns `null` (never throws), and the `reply`
436
+ tool surfaces that as `{ isError: true }`. For cross-process replies (spawned
437
+ sessions), configure a stable `server.replySecret` — see below.
438
+
439
+ ---
440
+
441
+ ## Event-triggered session spawning
442
+
443
+ By default a surviving event is **emitted** into the current session. A source can
444
+ instead (or additionally) **spawn a brand-new agent session** to handle the event,
445
+ via [`@a5c-ai/adapters`](https://www.npmjs.com/package/@a5c-ai/adapters) — which is
446
+ a regular **dependency** of this package. The spawned session is
447
+ **self-associated**: it is re-launched with *this* MCP server over stdio (the same
448
+ config path) and handed the event context plus the same `reply_to` token, so the
449
+ new session can post back to the **same origin** by calling the `reply` tool —
450
+ exactly like the in-session path.
451
+
452
+ ### `onEvent`: choosing emit / spawn / both
453
+
454
+ Each source has an `onEvent` mode (default `emit`, so existing configs are
455
+ unchanged and never spawn):
456
+
457
+ | `onEvent` | behavior |
458
+ |-----------|----------|
459
+ | `emit` *(default)* | push a `notifications/claude/channel` event into the current session only. |
460
+ | `spawn` | launch a fresh agent session for the event; do **not** emit. |
461
+ | `both` | emit **and** spawn (both carry the same minted `reply_to`). |
462
+
463
+ ```yaml
464
+ sources:
465
+ - id: gh-triage
466
+ backend: github
467
+ auth: { token: "${GITHUB_TOKEN}" }
468
+ config: { repo: "octo/app", events: [issue_opened] }
469
+ onEvent: spawn # emit | spawn | both (default emit)
470
+ ```
471
+
472
+ ### The `spawn` block (global defaults + per-source overrides)
473
+
474
+ A top-level `spawn:` block sets global defaults; a per-source `spawn:` block is
475
+ **merged over** it (per-source keys win; `env` is deep-merged):
476
+
477
+ ```yaml
478
+ spawn: # global defaults
479
+ agent: claude # adapters agent key (see below)
480
+ mode: headless # headless | interactive (default headless)
481
+ approvalMode: yolo # yolo | prompt | deny (default yolo, autonomous reply)
482
+ selfMcpName: mcp-channels # name of the self-association MCP entry (must match ^[A-Za-z0-9_-]{1,64}$)
483
+ maxConcurrent: 4 # bound on in-flight session launches
484
+
485
+ sources:
486
+ - id: gh-triage
487
+ backend: github
488
+ auth: { token: "${GITHUB_TOKEN}" }
489
+ config: { repo: "octo/app", events: [issue_opened] }
490
+ onEvent: spawn
491
+ spawn: # per-source overrides
492
+ model: claude-opus-4-8 # optional adapter model
493
+ cwd: "." # working dir (resolved absolute against the config dir)
494
+ systemPrompt: "..." # optional system prompt passthrough
495
+ env: { FOO: bar } # optional env passthrough
496
+ promptTemplate: "..." # optional; overrides the default prompt (placeholders below)
497
+ ```
498
+
499
+ **The `agent` key.** The default — and the canonical
500
+ [`@a5c-ai/adapters`](https://www.npmjs.com/package/@a5c-ai/adapters) registry id
501
+ for Claude Code — is **`claude`**. The friendly alias **`claude-code`** is also
502
+ accepted and is normalized to `claude` before launch, so either spelling resolves.
503
+
504
+ ### Prompt template placeholders
505
+
506
+ If you omit `promptTemplate`, a sensible default prompt is built containing the
507
+ event content, the routing-relevant meta, the `reply_to` token, and an instruction
508
+ to respond via the `reply` tool. To customize it, set `promptTemplate` with any of
509
+ these `{{…}}` placeholders (a single literal pass — untrusted event text can never
510
+ inject a *new* placeholder; an unknown key expands to the empty string):
511
+
512
+ | placeholder | expands to |
513
+ |-------------|-----------|
514
+ | `{{content}}` | the event body |
515
+ | `{{reply_to}}` | the opaque reply token (pass verbatim to the `reply` tool) |
516
+ | `{{source_id}}` | the source's `id` |
517
+ | `{{meta.KEY}}` | `event.meta.KEY` (e.g. `{{meta.repo}}`, `{{meta.issue_number}}`, `{{meta.issue_key}}`) |
518
+
519
+ ```yaml
520
+ spawn:
521
+ promptTemplate: |
522
+ New issue on {{meta.repo}}#{{meta.issue_number}} ({{source_id}}):
523
+ {{content}}
524
+
525
+ When done, call the `reply` tool with reply_to={{reply_to}}.
526
+ ```
527
+
528
+ ### Cross-process replies — `server.replySecret`
529
+
530
+ A spawned session is a **separate process**, so for its `reply` to decode a token
531
+ minted by the parent, both processes must derive the **same** HMAC key. Set a
532
+ stable shared secret via `server.replySecret` (typically from the environment); the
533
+ parent passes it to the spawned self-MCP entry as `MCP_CHANNELS_REPLY_SECRET`:
534
+
535
+ ```yaml
536
+ server:
537
+ name: mcp-channels
538
+ replySecret: "${MCP_CHANNELS_REPLY_SECRET}" # stable HMAC secret for cross-process replies
539
+ ```
540
+
541
+ The runtime also reads `MCP_CHANNELS_REPLY_SECRET` from the environment as a
542
+ fallback when `server.replySecret` is unset. **When no secret is configured**, the
543
+ token is signed with a per-process random key (the default) — single-process
544
+ replies still work, but a token minted by one process won't verify in another, so
545
+ configure a shared secret whenever you use `onEvent: spawn`/`both`.
546
+
547
+ ### Live-spawn prerequisites
548
+
549
+ Spawning a **live** session is out of scope for the offline test suite (the suite
550
+ always injects a fake client). A real launch additionally requires:
551
+
552
+ - the **agent CLI** for your chosen `agent` on `PATH` — for the default `claude`
553
+ agent that is the **`claude` CLI** (Claude Code),
554
+ - valid **agent auth** for that CLI (e.g. an Anthropic login/allowlist).
555
+
556
+ `@a5c-ai/adapters` is a regular dependency, so the launch path is always available;
557
+ when a source is configured to spawn but no adapters client can be obtained and
558
+ none is injected, this surfaces as a **clear startup error** from `createRuntime()`
559
+ — never a silent no-op at event time.
560
+
561
+ ---
562
+
563
+ ## Wiring to a real Claude Code session
564
+
565
+ > Channels are a **research preview**. Running a live Claude Code session requires
566
+ > the `claude` CLI, an Anthropic allowlist, and a development flag. The automated
567
+ > test suite verifies the whole pipeline **offline** with an in-memory MCP
568
+ > transport and mocked HTTP — this section is the manual wiring guide.
569
+
570
+ 1. **Register the server** with Claude Code via an `.mcp.json` at your project
571
+ root:
572
+
573
+ ```json
574
+ {
575
+ "mcpServers": {
576
+ "mcp-channels": {
577
+ "command": "adapters-channels",
578
+ "args": ["examples/channels.yml"]
579
+ }
580
+ }
581
+ }
582
+ ```
583
+
584
+ The `mcpServers` key (here `mcp-channels`) becomes the `source` attribute Claude
585
+ shows on each `<channel source="mcp-channels" …>` — so name it after the
586
+ channel, not the transport.
587
+
588
+ 2. **Export the env vars** your config interpolates (`GITHUB_TOKEN`, `JIRA_EMAIL`,
589
+ `JIRA_TOKEN`, …) in the shell that launches Claude Code.
590
+
591
+ 3. **Launch Claude Code with development channels enabled.** Channels are gated
592
+ behind a development flag; start the CLI with:
593
+
594
+ ```bash
595
+ claude --dangerously-load-development-channels
596
+ ```
597
+
598
+ Claude Code reads `.mcp.json`, spawns the channels stdio server, and sees its
599
+ `claude/channel` capability. From then on, every event that survives your
600
+ filters arrives in the session as:
601
+
602
+ ```
603
+ <channel source="mcp-channels" repo="octo/app" issue_number="42" kind="issue_comment" author="bob" reply_to="…opaque…">
604
+ …the comment text…
605
+ </channel>
606
+ ```
607
+
608
+ 4. **Reply to origin.** To respond, Claude calls the `reply` tool with the event's
609
+ exact `reply_to` value and the text to post. The framework decodes the token,
610
+ routes to the owning backend, and posts the comment back to the originating
611
+ GitHub issue/PR or Jira issue. (This guidance is also baked into the server's
612
+ default `instructions`.)
613
+
614
+ ### Optional: permission relay
615
+
616
+ Set `server.permissionRelay: true` to opt into the `claude/channel/permission`
617
+ capability. The runtime answers each inbound `permission_request` with exactly one
618
+ `permission` decision. The default policy is **deny** (untrusted inbound text is a
619
+ prompt-injection surface); supply a `permissionHandler(req) => 'allow' | 'deny'`
620
+ via `createRuntime`'s `deps` to implement sender-gating.
621
+
622
+ ---
623
+
624
+ ## Programmatic API
625
+
626
+ The package's main entry (`@a5c-ai/channels-adapter`) re-exports the framework
627
+ surface for embedding apps:
628
+
629
+ - `createRuntime(configPath, deps?)` — bootstrap a runtime (`{ server, poller,
630
+ start, stop }`); `deps` can inject a fake transport / clock / http / adapters
631
+ client for tests.
632
+ - `ChannelServer`, `DEFAULT_INSTRUCTIONS`, `Poller`.
633
+ - `defineBackend`, `registry`, `Registry`.
634
+ - `webhookBackend` (the built-in webhook backend module), plus the re-exported
635
+ `NormalizedTriggerEvent` / `TriggerBackend` types from `@a5c-ai/triggers-adapter`.
636
+ - `loadConfig`, `compileFilter`, `filterMatch`.
637
+ - `StateStore`, `MemoryStateStore`, `deriveNew`, `boundSeen`.
638
+ - `encodeReplyTo`, `decodeReplyTo`, `dispatchReply`, `createRelay`.
639
+ - `SessionSpawner`, `buildSpawnRunOptions`.
640
+
641
+ ---
642
+
643
+ ## Testing
644
+
645
+ The whole suite is offline (vitest): mocked GitHub/Jira HTTP, an in-memory MCP
646
+ transport, an injected fake adapters client for the spawner, and inline/injected-fs
647
+ queues for the webhook backend.
648
+
649
+ ```bash
650
+ npm test # vitest run
651
+ npm run test:coverage # vitest run --coverage (≥90% lines on src/ enforced)
652
+ ```
653
+
654
+ Coverage is measured on `src/**` only (`src/cli.ts` and the environment-gated
655
+ `@a5c-ai/adapters` real-launch branch are excluded as trivial / not offline-testable).
656
+
657
+ ---
658
+
659
+ ## License
660
+
661
+ [MIT](LICENSE).
@@ -0,0 +1,7 @@
1
+ import type { Backend } from './types.js';
2
+ /**
3
+ * Identity helper that gives custom-backend authors editor support (the
4
+ * `Backend` type) and asserts the required hooks are present, turning a missing
5
+ * hook into a clear authoring-time error rather than a confusing poll-time crash.
6
+ */
7
+ export declare function defineBackend<T extends Partial<Backend>>(backend: T): T;