@a5c-ai/channels-adapter
An MCP-server mini-framework that turns external systems (GitHub, Jira, inbound webhooks, …) into a Claude Code channel via a declarative YAML config — with pluggable backends, per-source polling + change detection + dedup, declarative filtering, a
replytool that relays Claude's replies back to the origin, and an optional per-event session spawner backed by@a5c-ai/adapters.
@a5c-ai/channels-adapter is a TypeScript (ESM) stdio MCP server. It:
- declares the experimental
claude/channelcapability, - polls pluggable backends on a per-source schedule,
- computes what changed since the last check (a per-source cursor) and dedupes so each external event triggers Claude at most once,
- filters events declaratively (assignee, label, project, substring/regex,
boolean
all/any/not), - pushes each surviving event into the session as a
notifications/claude/channelevent (Claude sees<channel source="…" …>content</channel>), - relays Claude's replies back to origin (a comment on the GitHub issue/PR or
Jira issue that triggered the event) through a single
replyMCP tool, and - optionally spawns a fresh agent session per event via
@a5c-ai/adapters, handing the spawned session the samereply_totoken so it can answer the same origin.
Backends are hook points — a tiny documented interface (poll, reply,
optional validateConfig/init) anyone can implement to add a new system without
touching the core. Three built-ins ship: github, jira, and webhook.
Design & spec: see docs/DESIGN.md, docs/SPEC.md.
Table of contents
- Install
- Quick start
- YAML configuration schema
- Built-in backends
- The Backend hook interface
- Writing a custom backend
- How polling, change-detection & dedup work
- The opaque
reply_totoken - Event-triggered session spawning
- Wiring to a real Claude Code session
- Programmatic API
- Testing
- License
Install
Requires Node.js ≥ 20.9 (developed/tested on Node 22).
npm i @a5c-ai/channels-adapterThe package is published with a build step (TypeScript → dist/); installing from
npm gives you the compiled dist/. It exposes two equivalent bin names:
adapters-channels— the canonical bin (matches the in-repo adapters family).mcp-channels— a back-compat alias for the original framework name.
Both run the same stdio MCP server.
Quick start
Write a config (see the schema). A ready example lives at
examples/channels.yml.Export the env vars your config interpolates:
export GITHUB_TOKEN=ghp_xxx export JIRA_EMAIL=you@example.com export JIRA_TOKEN=xxxRun the stdio server:
npx -p @a5c-ai/channels-adapter adapters-channels examples/channels.yml # or, with the alias: npx -p @a5c-ai/channels-adapter mcp-channels examples/channels.yml # or, against a checkout: node dist/cli.js examples/channels.yml
The process speaks MCP over stdio. To actually attach it to Claude Code, see Wiring to a real Claude Code session.
YAML configuration schema
server:
name: mcp-channels # MCP server name → <channel source="mcp-channels">
instructions: "...optional override..." # else a sane default is used
permissionRelay: false # opt-in claude/channel/permission relay
replySecret: "${MCP_CHANNELS_REPLY_SECRET}" # optional; stable HMAC secret (see spawning)
state:
dir: ./.mcp-channels-state # optional; default ~/.claude/channels/<name>/state
maxSeenPerSource: 1000 # FIFO-bounded per-source dedup seen-set
defaults:
pollIntervalSeconds: 60 # per-source override falls back to this
sources:
- id: gh-comments-by-alice # unique id; also the state-file key
backend: github # a built-in type (github|jira|webhook) OR ./relative/custom.js
pollIntervalSeconds: 30
auth: { token: "${GITHUB_TOKEN}" }
config: { repo: "octo/app", events: [issue_comment] }
filter:
all:
- { field: "issue.assignee.login", op: eq, value: "alice" }
routing: { reply: comment } # default reply mode per backend
- id: jira-crash-bugs
backend: jira
auth:
baseUrl: "https://x.atlassian.net"
email: "${JIRA_EMAIL}"
token: "${JIRA_TOKEN}"
config: { project: "BUG", events: [issue_created] }
filter:
all:
- { field: "fields.labels", op: includes, value: "needs-triage" }
- { field: "fields.summary", op: contains, value: "crash", ignoreCase: true }${ENV} interpolation
${NAME} placeholders are read from process.env at load time. A missing
required variable is a validation error — never a crash at poll time.
Validation
loadConfig() never throws: malformed YAML, an unresolved ${ENV}, an unknown
backend type, a malformed filter, or a backend's own validateConfig() failure
are all collected into an errors: string[] array. createRuntime() throws once,
up front, with all the aggregated messages if the config is invalid — including
errors from the built-in github/jira/webhook backends (e.g. a github
config.repo that is not "owner/name", a jira source missing auth.token, or a
webhook source with an unrecognized config.backend). Custom-path backends are
imported and validated at startup, so a broken custom backend (missing
poll/reply) fails createRuntime, not the first tick.
The filter engine
A filter is either a leaf clause { field, op, value, ignoreCase? } over a
dot-path into the event payload, or a combinator { all: [...] },
{ any: [...] }, or { not: clause }.
| op | meaning |
|---|---|
eq / ne |
strict equal / not-equal |
in / nin |
field value is / is not in a list |
includes |
the field is an array containing value |
contains |
the field is a string containing substring value |
regex |
the field is a string matching the value pattern |
exists |
path is present (value:true) / absent (value:false) |
gt gte lt lte |
numeric comparisons |
contains and regex honor ignoreCase: true. An unknown op, a bad/missing
dot-path, or a malformed regex yields no match and never throws — a
misconfigured filter can't crash a poll. An empty/missing filter matches
everything.
Built-in backends
github
- Auth:
auth.token(a PAT). Basehttps://api.github.com(override withconfig.baseUrlfor GitHub Enterprise). - Config:
repo: "owner/name",events: [issue_comment | issue_opened | pr_opened]. issue_comment:GET /repos/{o}/{r}/issues/comments?sort=updated&direction=asc&since=<cursor>. The cursor is the maxupdated_at. Each comment resolves its parent issue/PR (one cached GET) so filters likeissue.assignee.loginwork. Dedup id isgh:comment:<id>(an edit does not re-trigger; setconfig.retriggerOnEdit: trueto make<id>:<updated_at>the id).issue_opened:GET /repos/{o}/{r}/issues?state=open&sort=created&direction=asc&since=<cursor>, post-filtered tocreated_at >= cursor(+ seen-set dedup, so equal-timestamp creations aren't dropped). Dedup idgh:issue:<id>.- Pagination: follows the
Link: rel="next"header and accumulates all pages before advancing the cursor. - Failure handling: a non-2xx list response advances nothing (cursor and seen are kept) so the window is retried; a failed parent-issue fetch holds the comment for the next poll instead of dropping it.
- meta:
{ repo, issue_number, kind, author, reply_to }. - reply:
POST /repos/{o}/{r}/issues/{issue_number}/comments { body: text }.
jira
- Auth:
auth.baseUrl,auth.email,auth.token(HTTP Basic). - Config:
project([A-Za-z0-9_]+),events: [issue_created | issue_updated], optionalconfig.jqlextra clause. - Poll:
POST /rest/api/3/searchwith JQLproject = "<P>" AND created >= "<cursor-minute>" ORDER BY created ASC(updatedforissue_updated). The cursor is stored at full precision but the JQL literal is the minute-granular"yyyy-MM-dd HH:mm"Jira accepts. - Pagination: loops
startAtuntilstartAt + len >= total, accumulating all pages before advancing the cursor. - Dedup: because JQL datetime comparisons are minute-granular, the cursor is
combined with a seen-set keyed by
jira:<key>:<created>(full timestamp) so a same-minute issue re-returned next poll is recognized and dropped. - Security:
config.projectis validated against[A-Za-z0-9_]+and anyconfig.jqlis stripped of quote/backslash/semicolon so neither can inject into the JQL string. - meta:
{ project, issue_key, kind, reply_to }. - reply:
POST /rest/api/3/issue/{key}/commentwith an ADF body.
webhook (optional, additive)
An additive built-in backend (it does not replace or alter github/jira)
that turns inbound webhook payloads (GitHub / GitLab / Bitbucket / generic)
into channel events by delegating the parsing to
@a5c-ai/triggers-adapter's
normalizeEvent(backend, eventName, payload) — the same normalizer the
triggers-adapter uses for CI/action triggers — so the channels framework reuses
that battle-tested webhook-shape knowledge instead of re-deriving it.
Channels is poll-based, so this backend reads a queue of already-captured webhook payloads and normalizes each on every poll (live HTTP receipt — a bound listener / tunnel — is intentionally out of scope, mirroring how live agent launch is out of offline-test scope):
- Auth: none (webhooks are inbound; nothing is posted back).
- Config:
backend: github | gitlab | bitbucket | generic-webhook— which webhook shapenormalizeEventshould parse (unrecognized values are a validation error).payloads: [...]— an inline array of captured webhook entries (the injection seam: each entry is{ eventName, payload, id? }, or a bare payload object whoseeventNamefalls back toconfig.eventName).dir: "./captured"— a directory of captured*.jsonpayload files (read through an injectableconfig.fs, defaulting tonode:fs/promises; files are sorted by name for stable emit order; an unparseable file is skipped with a log line, not a poll failure).eventName: "..."— optional fallback event name for bare-payload entries.- At least one of
payloads/diris required.
- Dedup id: the entry's explicit
id(e.g. a GitHubX-GitHub-Delivery) when present, else a deterministic id derived from the normalized shape (webhook:<backend>:<event>:<action>:<sha|url|repo#ref>). - meta:
{ backend, event, action?, repo?, author?, ref?, sha?, source_branch?, target_branch?, url? }(empties dropped; values stringified). - payload: the raw upstream payload (so declarative dot-path filters match).
- reply: unsupported — a webhook is a one-way inbound notification with no
generic callback channel, so
reply()throws a clear, actionable error rather than silently no-op'ing. To reply, route through the concretegithub/jirabackend the payload originated from.
The Backend hook interface
A backend is a module that default-exports an object implementing the Backend
interface (TypeScript types ship in dist/index.d.ts):
interface ChannelEvent {
id: string;
content: string;
meta: Record<string, string>;
payload: object;
routing: object;
}
interface PollContext { source: object; state: object; http: Function; log: Function; now: Date }
interface PollResult { events: ChannelEvent[]; state: object }
interface Backend {
type: string;
validateConfig?(source: object): string[];
init?(source: object): void | Promise<void>;
poll(ctx: PollContext): Promise<PollResult>;
reply(a: { routing: object; text: string; source: object; http: Function }):
Promise<{ ok: boolean; ref?: string }>;
}type— stable identifier (used in logs and as the registry key).validateConfig(source) → string[](optional) — runs at config-load time; return human-readable problems (empty == valid). This is how misconfiguration becomes a validation error instead of a poll-time crash.init(source)(optional) — runs once before the first poll of a source.poll(ctx) → { events, state }— the heart of a backend. It MUST be pure w.r.t. side effects except HTTP via the injectedctx.http(so tests inject a fake), MUST usectx.state.cursorto request only changes since last check where the API supports it, and MUST setroutingon every event so a reply can reach origin. It returns the new events and the next state to persist.reply({ routing, text, source, http }) → { ok, ref? }— poststextback to the origin identified byrouting, usingsource.authand the injectedhttp. A falsyokor a thrown error becomes a tool result withisError:true.
The core (not the backend) is the authoritative filter + dedup gate. A backend MAY pre-filter at the API for efficiency but MUST NOT rely on it for correctness.
defineBackend(obj) is an identity helper that gives editors the Backend type
and asserts the required poll/reply hooks are present.
Writing a custom backend
Reference a custom module from YAML by relative path instead of a built-in type. The path resolves relative to the config file:
sources:
- id: my-thing
backend: ./examples/custom-backend.js # resolved by registry.load()
pollIntervalSeconds: 30
auth: { token: "${MY_TOKEN}" }
config: { endpoint: "https://example.test/api/events" }
filter:
all:
- { field: "kind", op: eq, value: "mention" }A minimal backend (importing the authoring helper from the package):
import { defineBackend } from '@a5c-ai/channels-adapter';
export default defineBackend({
type: 'example-custom',
validateConfig(source) {
const errors = [];
if (!source?.config?.endpoint) errors.push('config.endpoint is required');
if (!source?.auth?.token) errors.push('auth.token is required');
return errors;
},
async poll(ctx) {
const { source, state, http } = ctx;
const cursor = state?.cursor ?? null;
const seen = state?.seen ?? [];
const url = new URL(source.config.endpoint);
if (cursor) url.searchParams.set('since', cursor); // ask for "since last time"
const res = await http(url.toString(), {
headers: { authorization: `Bearer ${source.auth.token}` }
});
const items = Array.isArray(res?.body?.items) ? res.body.items : [];
const seenSet = new Set(seen);
const fresh = items.filter((it) => !seenSet.has(String(it.id)));
const events = fresh.map((it) => ({
id: `example:${it.id}`, // stable dedup id
content: it.text ?? '',
meta: { kind: String(it.kind), author: String(it.author) },
payload: it, // raw object for dot-path filters
routing: { endpoint: source.config.endpoint, itemId: it.id } // reply needs this
}));
const nextCursor = fresh.reduce((a, it) => (it.updatedAt > a ? it.updatedAt : a), cursor ?? '');
return { events, state: { cursor: nextCursor, seen: [...seen, ...fresh.map((it) => String(it.id))] } };
},
async reply({ routing, text, source, http }) {
const res = await http(`${routing.endpoint}/${routing.itemId}/replies`, {
method: 'POST',
headers: { authorization: `Bearer ${source.auth.token}`, 'content-type': 'application/json' },
body: JSON.stringify({ text })
});
return { ok: res?.status >= 200 && res?.status < 300, ref: res?.body?.id };
}
});A complete, commented version lives at
examples/custom-backend.js. You can also register
a backend programmatically: registry.register('my-type', backend).
How polling, change-detection & dedup work
State per source is { cursor, seen[] }, persisted as JSON (atomic write) under
state.dir (default ~/.claude/channels/<name>/state). seen is bounded
(maxSeenPerSource, FIFO).
On each tick for a source (Poller.tick(id)):
- load state →
{ cursor, seen }. - poll the backend with
{ source, state, http, log, now }. The backend queries the upstream API narrowed bycursor, paginating to fetch the whole window, and returns new events + the next state. - filter (core): keep events whose
payloadsatisfiessource.filter. - dedup (core): drop events whose id is already in
seen. This is the authoritative at-most-once gate, even when polling windows overlap (an inclusivesince, a minute-granular JQL>=, …). - mint
reply_toand attach it to each survivor'smeta. - persist state: the cursor advances;
seengrows (boundary-safe FIFO). - dispatch each survivor per the source's
onEventmode (emit/spawn/both, defaultemit) —emitsends onenotifications/claude/channel.
Two correctness details worth knowing:
- Boundary-safe pruning. The FIFO bound never evicts an id whose timestamp is still inside the cursor window (the "boundary bucket"). A naive count-based FIFO could drop such an id and then re-emit it on the next overlapping poll; the bound is soft and retains the boundary bucket so that can't happen.
- Serialized ticks. Ticks for the same source are serialized — a second tick that arrives while the first is running is queued behind it, so two overlapping ticks can't read the same prior state and clobber each other's cursor/seen. Ticks for different sources run concurrently.
The opaque reply_to token
A reply must reach the exact origin, but Claude only echoes one small
attribute back, and inbound channel text is an untrusted prompt-injection surface.
So the framework mints a single opaque, tamper-evident routing token per event
and exposes it as meta.reply_to. Claude treats it as a black box and passes it
verbatim to the reply tool.
- The token is
<base64url(JSON)>.<base64url(HMAC-SHA256)>, URL-safe and a valid channel attribute value. - By default it is signed with a per-process random secret generated at
startup, so a forged or tampered token (even a single flipped character, or a
hand-rolled
base64url(JSON)without the signature) fails verification. - It carries no upstream credentials — auth lives only in the loaded config, keyed by source id. The HMAC exists so the runtime never POSTs under real credentials to a routing target it didn't itself mint.
Decoding a bad/garbled/forged token returns null (never throws), and the reply
tool surfaces that as { isError: true }. For cross-process replies (spawned
sessions), configure a stable server.replySecret — see below.
Event-triggered session spawning
By default a surviving event is emitted into the current session. A source can
instead (or additionally) spawn a brand-new agent session to handle the event,
via @a5c-ai/adapters — which is
a regular dependency of this package. The spawned session is
self-associated: it is re-launched with this MCP server over stdio (the same
config path) and handed the event context plus the same reply_to token, so the
new session can post back to the same origin by calling the reply tool —
exactly like the in-session path.
onEvent: choosing emit / spawn / both
Each source has an onEvent mode (default emit, so existing configs are
unchanged and never spawn):
onEvent |
behavior |
|---|---|
emit (default) |
push a notifications/claude/channel event into the current session only. |
spawn |
launch a fresh agent session for the event; do not emit. |
both |
emit and spawn (both carry the same minted reply_to). |
sources:
- id: gh-triage
backend: github
auth: { token: "${GITHUB_TOKEN}" }
config: { repo: "octo/app", events: [issue_opened] }
onEvent: spawn # emit | spawn | both (default emit)The spawn block (global defaults + per-source overrides)
A top-level spawn: block sets global defaults; a per-source spawn: block is
merged over it (per-source keys win; env is deep-merged):
spawn: # global defaults
agent: claude # adapters agent key (see below)
mode: headless # headless | interactive (default headless)
approvalMode: yolo # yolo | prompt | deny (default yolo, autonomous reply)
selfMcpName: mcp-channels # name of the self-association MCP entry (must match ^[A-Za-z0-9_-]{1,64}$)
maxConcurrent: 4 # bound on in-flight session launches
sources:
- id: gh-triage
backend: github
auth: { token: "${GITHUB_TOKEN}" }
config: { repo: "octo/app", events: [issue_opened] }
onEvent: spawn
spawn: # per-source overrides
model: claude-opus-4-8 # optional adapter model
cwd: "." # working dir (resolved absolute against the config dir)
systemPrompt: "..." # optional system prompt passthrough
env: { FOO: bar } # optional env passthrough
promptTemplate: "..." # optional; overrides the default prompt (placeholders below)The agent key. The default — and the canonical
@a5c-ai/adapters registry id
for Claude Code — is claude. The friendly alias claude-code is also
accepted and is normalized to claude before launch, so either spelling resolves.
Prompt template placeholders
If you omit promptTemplate, a sensible default prompt is built containing the
event content, the routing-relevant meta, the reply_to token, and an instruction
to respond via the reply tool. To customize it, set promptTemplate with any of
these {{…}} placeholders (a single literal pass — untrusted event text can never
inject a new placeholder; an unknown key expands to the empty string):
| placeholder | expands to |
|---|---|
{{content}} |
the event body |
{{reply_to}} |
the opaque reply token (pass verbatim to the reply tool) |
{{source_id}} |
the source's id |
{{meta.KEY}} |
event.meta.KEY (e.g. {{meta.repo}}, {{meta.issue_number}}, {{meta.issue_key}}) |
spawn:
promptTemplate: |
New issue on {{meta.repo}}#{{meta.issue_number}} ({{source_id}}):
{{content}}
When done, call the `reply` tool with reply_to={{reply_to}}.Cross-process replies — server.replySecret
A spawned session is a separate process, so for its reply to decode a token
minted by the parent, both processes must derive the same HMAC key. Set a
stable shared secret via server.replySecret (typically from the environment); the
parent passes it to the spawned self-MCP entry as MCP_CHANNELS_REPLY_SECRET:
server:
name: mcp-channels
replySecret: "${MCP_CHANNELS_REPLY_SECRET}" # stable HMAC secret for cross-process repliesThe runtime also reads MCP_CHANNELS_REPLY_SECRET from the environment as a
fallback when server.replySecret is unset. When no secret is configured, the
token is signed with a per-process random key (the default) — single-process
replies still work, but a token minted by one process won't verify in another, so
configure a shared secret whenever you use onEvent: spawn/both.
Live-spawn prerequisites
Spawning a live session is out of scope for the offline test suite (the suite always injects a fake client). A real launch additionally requires:
- the agent CLI for your chosen
agentonPATH— for the defaultclaudeagent that is theclaudeCLI (Claude Code), - valid agent auth for that CLI (e.g. an Anthropic login/allowlist).
@a5c-ai/adapters is a regular dependency, so the launch path is always available;
when a source is configured to spawn but no adapters client can be obtained and
none is injected, this surfaces as a clear startup error from createRuntime()
— never a silent no-op at event time.
Wiring to a real Claude Code session
Channels are a research preview. Running a live Claude Code session requires the
claudeCLI, an Anthropic allowlist, and a development flag. The automated test suite verifies the whole pipeline offline with an in-memory MCP transport and mocked HTTP — this section is the manual wiring guide.
Register the server with Claude Code via an
.mcp.jsonat your project root:{ "mcpServers": { "mcp-channels": { "command": "adapters-channels", "args": ["examples/channels.yml"] } } }The
mcpServerskey (heremcp-channels) becomes thesourceattribute Claude shows on each<channel source="mcp-channels" …>— so name it after the channel, not the transport.Export the env vars your config interpolates (
GITHUB_TOKEN,JIRA_EMAIL,JIRA_TOKEN, …) in the shell that launches Claude Code.Launch Claude Code with development channels enabled. Channels are gated behind a development flag; start the CLI with:
claude --dangerously-load-development-channelsClaude Code reads
.mcp.json, spawns the channels stdio server, and sees itsclaude/channelcapability. From then on, every event that survives your filters arrives in the session as:<channel source="mcp-channels" repo="octo/app" issue_number="42" kind="issue_comment" author="bob" reply_to="…opaque…"> …the comment text… </channel>Reply to origin. To respond, Claude calls the
replytool with the event's exactreply_tovalue and the text to post. The framework decodes the token, routes to the owning backend, and posts the comment back to the originating GitHub issue/PR or Jira issue. (This guidance is also baked into the server's defaultinstructions.)
Optional: permission relay
Set server.permissionRelay: true to opt into the claude/channel/permission
capability. The runtime answers each inbound permission_request with exactly one
permission decision. The default policy is deny (untrusted inbound text is a
prompt-injection surface); supply a permissionHandler(req) => 'allow' | 'deny'
via createRuntime's deps to implement sender-gating.
Programmatic API
The package's main entry (@a5c-ai/channels-adapter) re-exports the framework
surface for embedding apps:
createRuntime(configPath, deps?)— bootstrap a runtime ({ server, poller, start, stop });depscan inject a fake transport / clock / http / adapters client for tests.ChannelServer,DEFAULT_INSTRUCTIONS,Poller.defineBackend,registry,Registry.webhookBackend(the built-in webhook backend module), plus the re-exportedNormalizedTriggerEvent/TriggerBackendtypes from@a5c-ai/triggers-adapter.loadConfig,compileFilter,filterMatch.StateStore,MemoryStateStore,deriveNew,boundSeen.encodeReplyTo,decodeReplyTo,dispatchReply,createRelay.SessionSpawner,buildSpawnRunOptions.
Testing
The whole suite is offline (vitest): mocked GitHub/Jira HTTP, an in-memory MCP transport, an injected fake adapters client for the spawner, and inline/injected-fs queues for the webhook backend.
npm test # vitest run
npm run test:coverage # vitest run --coverage (≥90% lines on src/ enforced)Coverage is measured on src/** only (src/cli.ts and the environment-gated
@a5c-ai/adapters real-launch branch are excluded as trivial / not offline-testable).
License
MIT.