@a5c-ai/channels-adapter 5.1.1-staging.066218fc5190
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/LICENSE +21 -0
- package/README.md +661 -0
- package/dist/backend.d.ts +7 -0
- package/dist/backend.d.ts.map +1 -0
- package/dist/backend.js +22 -0
- package/dist/backend.js.map +1 -0
- package/dist/backends/github.d.ts +16 -0
- package/dist/backends/github.d.ts.map +1 -0
- package/dist/backends/github.js +357 -0
- package/dist/backends/github.js.map +1 -0
- package/dist/backends/jira.d.ts +18 -0
- package/dist/backends/jira.d.ts.map +1 -0
- package/dist/backends/jira.js +256 -0
- package/dist/backends/jira.js.map +1 -0
- package/dist/backends/webhook.d.ts +25 -0
- package/dist/backends/webhook.d.ts.map +1 -0
- package/dist/backends/webhook.js +206 -0
- package/dist/backends/webhook.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +28 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +10 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +323 -0
- package/dist/config.js.map +1 -0
- package/dist/dedup.d.ts +26 -0
- package/dist/dedup.d.ts.map +1 -0
- package/dist/dedup.js +68 -0
- package/dist/dedup.js.map +1 -0
- package/dist/filter.d.ts +9 -0
- package/dist/filter.d.ts.map +1 -0
- package/dist/filter.js +115 -0
- package/dist/filter.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -0
- package/dist/poller.d.ts +63 -0
- package/dist/poller.d.ts.map +1 -0
- package/dist/poller.js +195 -0
- package/dist/poller.js.map +1 -0
- package/dist/registry.d.ts +22 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +59 -0
- package/dist/registry.js.map +1 -0
- package/dist/relay.d.ts +58 -0
- package/dist/relay.d.ts.map +1 -0
- package/dist/relay.js +172 -0
- package/dist/relay.js.map +1 -0
- package/dist/runtime.d.ts +27 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +198 -0
- package/dist/runtime.js.map +1 -0
- package/dist/server.d.ts +64 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +217 -0
- package/dist/server.js.map +1 -0
- package/dist/spawner.d.ts +96 -0
- package/dist/spawner.d.ts.map +1 -0
- package/dist/spawner.js +368 -0
- package/dist/spawner.js.map +1 -0
- package/dist/state.d.ts +43 -0
- package/dist/state.d.ts.map +1 -0
- package/dist/state.js +92 -0
- package/dist/state.js.map +1 -0
- package/dist/types.d.ts +140 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +10 -0
- package/dist/types.js.map +1 -0
- 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;
|