Claude Code Channels
Source: This skill is based on the official Channels reference from Anthropic's Claude Code documentation. Always consult it for the latest protocol details.
Build channel plugins that push webhooks, alerts, chat messages, and external events into a running Claude Code session. Channels are MCP server plugins that bridge external systems into Claude Code's interactive agent loop — the same session you'd normally use from the terminal.
Why Channels Matter
Channels solve a specific problem: you want Claude Code to react to things happening outside the terminal while preserving its context, tools, and agent loop. Unlike claude -p (which starts a fresh one-shot subprocess per prompt), channels push events into a long-lived interactive Claude Code session that maintains working memory, MCP tools, and accumulated knowledge across messages.
The other major motivation is quota economics. Starting June 15, 2026, Anthropic separated quotas:
| Path | Quota pool | Limit |
|---|---|---|
| Interactive Claude Code (terminal, channels, Remote Control, Claude Cowork) | Main subscription quota | Generous |
Agent SDK + claude -p |
$20/month SDK credit | Limited |
Channels use the main subscription quota because they run inside an interactive session.
Architecture
A channel is an MCP server that Claude Code spawns as a subprocess over stdio. It also runs a network listener (WebSocket or HTTP) for external applications:
┌──────────────┐ ┌──────────────────┐
│ External │ ◄── WebSocket ──► │ Channel Plugin │
│ Application │ or HTTP │ (MCP server) │
└──────────────┘ └────────┬─────────┘
│ stdio (MCP)
┌──────▼──────────┐
│ Claude Code │
│ (interactive │
│ session) │
└──────────────────┘
Events are delivered as XML <channel> tags in Claude's context — NOT as raw user prompts. The instructions field in the server constructor tells Claude what <channel> tag format to expect and how to respond.
- One-way channels: Forward alerts, webhooks, CI failures. Claude acts but doesn't reply through the channel. Omit
tools: {}from capabilities. - Two-way channels (chat bridges): Expose a
replyMCP tool. Claude calls it to send messages back.
Requirements
- Claude Code v2.1.80+ (v2.1.81+ for permission relay) — research preview
- Bun, Node.js, or Deno (Bun is standard; pre-built plugins use it)
@modelcontextprotocol/sdknpm package- Anthropic auth via claude.ai subscription or Console API key
- Not available on Bedrock, Vertex AI, or Foundry
- Team/Enterprise orgs must explicitly enable channels
Channels are in research preview. Non-allowlisted channels require --dangerously-load-development-channels.
Starting Claude Code with a Channel
# During research preview — development flag for local/non-allowlisted channels:
claude --dangerously-load-development-channels server:webhook
claude --dangerously-load-development-channels plugin:myplugin@mymarketplace
# From marketplace (after allowlisting or org policy):
claude --channels plugin:channel-name
# Resume an existing session:
claude -c --dangerously-load-development-channels server:webhook
Important: --dangerously-load-development-channels is per-entry. Combining it with --channels does NOT extend bypass to --channels entries. Each needs its own.
Building a Channel Plugin
Plugin Structure
my-channel/
├── .claude-plugin/
│ └── plugin.json # Metadata (optional; can use raw .mcp.json)
├── server.ts # Entry point
├── package.json
└── tsconfig.json
Minimal One-Way Webhook Receiver
This complete example listens on port 8788, forwards every POST body to Claude as a <channel> tag:
#!/usr/bin/env bun
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
// Create the MCP server with channel capability declared
const mcp = new Server(
{ name: 'webhook', version: '0.0.1' },
{
capabilities: {
experimental: { 'claude/channel': {} }, // REQUIRED: registers the notification listener
},
// Goes into Claude's system prompt — tells it what <channel> tags to expect
instructions:
'Events from the webhook channel arrive as <channel source="webhook" ...>. ' +
'They are one-way: read them and act, no reply expected.',
},
);
// Connect to Claude Code over stdio
await mcp.connect(new StdioServerTransport());
// HTTP listener: every POST body forwarded to Claude
Bun.serve({
port: 8788,
hostname: '127.0.0.1', // localhost-only; nothing outside this machine can POST
async fetch(req) {
const body = await req.text();
await mcp.notification({
method: 'notifications/claude/channel',
params: {
content: body, // becomes the body of the <channel> tag
meta: { path: new URL(req.url).pathname, method: req.method },
// each meta key becomes an attribute on the <channel> tag
},
});
return new Response('ok');
},
});
Register in .mcp.json:
{
"mcpServers": {
"webhook": { "command": "bun", "args": ["./server.ts"] }
}
}
Test:
claude --dangerously-load-development-channels server:webhook
# In another terminal:
curl -X POST localhost:8788 -d "build failed on main: https://ci.example.com/run/1234"
Claude receives:
<channel source="webhook" path="/" method="POST">
build failed on main: https://ci.example.com/run/1234
</channel>
The Channel Contract
Server Constructor Options
The Server constructor takes (info, options) where options has:
| Field | Required | Description |
|---|---|---|
capabilities.experimental['claude/channel'] |
Yes | {}. Registers the channel notification listener. |
capabilities.experimental['claude/channel/permission'] |
No | {}. Opts in to permission relay (v2.1.81+). |
capabilities.tools |
Two-way only | {}. Enables tool discovery for reply tool. |
instructions |
Recommended | String added to Claude's system prompt. Tell Claude what <channel> tag attributes to expect, whether to reply, and which tool to use. |
Do NOT put tools: {} for one-way channels. Omit it entirely.
Notification Format — Pushing Events INTO Claude
Call mcp.notification() with method notifications/claude/channel:
await mcp.notification({
method: 'notifications/claude/channel',
params: {
content: string, // Required: event body → becomes <channel> tag body
meta: Record<string, string>, // Optional: each entry becomes a <channel> tag attribute
},
});
Meta key restrictions: must be identifiers — letters, digits, and underscores only. Hyphens and other characters are silently dropped.
The event arrives in Claude's context as an XML tag:
<channel source="your-channel" severity="high" run_id="1234">
build failed on main
</channel>
The source attribute is auto-set from your server name.
Delivery behavior: Notifications are NOT acknowledged. await resolves when the message is written to the transport — not when Claude processes it. Events queue into the session and are processed in order. Multiple events arriving while Claude is busy are delivered together on the next turn.
Exposing a Reply Tool — Two-Way Channel
For two-way channels, register a tool that Claude calls to respond. Use setRequestHandler with ListToolsRequestSchema and CallToolRequestSchema:
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [{
name: 'reply',
description: 'Send a message back over this channel',
inputSchema: {
type: 'object',
properties: {
chat_id: { type: 'string', description: 'The conversation to reply in' },
text: { type: 'string', description: 'The message to send' },
},
required: ['chat_id', 'text'],
},
}],
}));
mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
if (req.params.name === 'reply') {
const { chat_id, text } = req.params.arguments as { chat_id: string; text: string };
// Send outbound — via WebSocket, SSE, or your chat platform's API
sendToClient(chat_id, text);
return { content: [{ type: 'text', text: 'sent' }] };
}
throw new Error(`unknown tool: ${req.params.name}`);
});
Also update instructions so Claude knows to call the reply tool:
instructions:
'Messages arrive as <channel source="webhook" chat_id="...">. ' +
'Reply with the reply tool, passing the chat_id from the tag.',
Gating Inbound Messages
Gate on sender identity (not chat/room identity) before forwarding to Claude. Otherwise it's a prompt injection vector:
const allowed = new Set(['dev', 'ci-bot']);
// In your inbound handler:
const sender = req.headers.get('X-Sender') ?? '';
if (!allowed.has(sender)) return new Response('forbidden', { status: 403 });
// Only then forward to Claude:
await mcp.notification({ method: 'notifications/claude/channel', params: { content: body, meta: {} } });
The built-in Telegram/Discord channels gate on sender ID from the platform's message object (message.from.id, not message.chat.id in group chats).
Permission Relay — Approve Tool Use Remotely
Permission relay (v2.1.81+) forwards Claude Code's tool-use approval prompts to your channel so users can approve/deny from another device. The local terminal dialog stays open too — whichever answer arrives first wins.
Three components:
1. Declare the capability in Server constructor:
capabilities: {
experimental: {
'claude/channel': {},
'claude/channel/permission': {}, // opt in to permission relay
},
tools: {},
},
2. Handle incoming permission requests — Claude Code pushes them via notifications/claude/channel/permission_request:
import { z } from 'zod';
const PermissionRequestSchema = z.object({
method: z.literal('notifications/claude/channel/permission_request'),
params: z.object({
request_id: z.string(), // 5 lowercase letters (a-km-z, never 'l')
tool_name: z.string(), // e.g. "Bash", "Write"
description: z.string(), // human-readable summary
input_preview: z.string(), // tool args as JSON, truncated to ~200 chars
}),
});
mcp.setNotificationHandler(PermissionRequestSchema, async ({ params }) => {
sendToClient(
`Claude wants to run ${params.tool_name}: ${params.description}\n\n` +
`Reply "yes ${params.request_id}" or "no ${params.request_id}"`,
);
});
request_id is five letters from a-z excluding l (never ambiguous as 1 or I). Only the channel sees this ID — it's not shown in the terminal dialog.
3. Intercept verdict replies in your inbound handler. The verdict format is yes <id> or no <id>. Match it BEFORE forwarding as chat:
// [a-km-z] is the ID alphabet (skips 'l'); /i tolerates phone autocorrect caps
const PERMISSION_REPLY_RE = /^\s*(y|yes|n|no)\s+([a-km-z]{5})\s*$/i;
// Inside your inbound message handler, before forwarding to Claude:
const m = PERMISSION_REPLY_RE.exec(body);
if (m) {
await mcp.notification({
method: 'notifications/claude/channel/permission',
params: {
request_id: m[2].toLowerCase(), // normalize autocorrect caps
behavior: m[1].toLowerCase().startsWith('y') ? 'allow' : 'deny',
},
});
return new Response('verdict recorded');
}
// Not a verdict — fall through to normal chat forwarding
Reference Implementations
claude-socket — WebSocket Bridge
github.com/cunicopia-dev/claude-socket (Apache 2.0) — A general-purpose WebSocket bridge for Claude Code's channel system. Architecture: App ↔ WebSocket ↔ claude-socket (MCP stdio) ↔ Claude Code. Supports session multiplexing, permission relay, message history, auto-reconnect, and a zero-dependency browser client.
Official Plugin Sources
Study these for production patterns (pairing flows, file attachments, message editing):
- All official channel plugins
- fakechat — web UI demo with SSE + reply tool
Augmenting Channels with Hooks
Channels capture what Claude voluntarily calls (reply tool). For guaranteed event capture (every tool execution, session lifecycle), combine with Claude Code hooks, which fire at lifecycle points and can HTTP POST to a local receiver in your channel plugin.
Hooks Configuration (for example <CLAUDE_HOME>/settings.json)
{
"hooks": {
"PostToolUse": [{
"matcher": "*",
"hooks": [{"type": "http", "url": "http://127.0.0.1:3101/hook/post-tool-use"}]
}],
"Stop": [{
"matcher": "*",
"hooks": [{"type": "http", "url": "http://127.0.0.1:3101/hook/stop"}]
}],
"SessionStart": [{
"matcher": "*",
"hooks": [{"type": "http", "url": "http://127.0.0.1:3101/hook/session-start"}]
}],
"UserPromptSubmit": [{
"matcher": "*",
"hooks": [{"type": "http", "url": "http://127.0.0.1:3101/hook/user-prompt"}]
}]
}
}
| Hook | Captured Data |
|---|---|
| PostToolUse | tool_name, tool_input, tool_output, tool_call_id, tool_error |
| Stop | usage (input_tokens, output_tokens, cache_read, cache_write), stop_reason |
| SessionStart | model, cwd, tools, session_id |
| UserPromptSubmit | prompt_text, timestamp |
Hybrid Architecture (Channels + Hooks)
┌──────────────────────────────────────────────────────────┐
│ Channel Plugin (Bun process) │
│ │
│ WebSocket Server (:3100) ← Your App connects here │
│ MCP Server (stdio) ↔ Claude Code │
│ HTTP Hook Receiver (:3101) ← Hooks POST here │
│ │
│ Event Bridge: │
│ WS prompt → notifications/claude/channel → Claude │
│ Hook POST → WS event broadcast │
│ Claude reply tool → WS reply event │
└──────────────────────────────────────────────────────────┘
Channels handle intentional communication (prompts, replies). Hooks handle guaranteed events (tool execution, session lifecycle).
Interactive Mode Implications
Using --channels means Claude Code runs in interactive mode (not -p headless). Key constraints:
- No
--output-format stream-json— only works with--print/-p - No per-tool NDJSON on stdout — stdout is terminal-formatted text
- No
--include-partial-messages(streaming text deltas) - Stdout is not machine-readable — use hooks for event data
Sessions persist automatically to <CLAUDE_HOME>/projects/<encoded-cwd>/<session-id>.jsonl. Resume with:
claude -c --dangerously-load-development-channels server:webhook
claude --resume <session-id> --dangerously-load-development-channels server:webhook
Integration Patterns
Web UI Bridge
Browser ↔ WebSocket ↔ channel-plugin (MCP) ↔ Claude Code
- Map browser UI sessions to
chat_idin meta attributes - Broadcast
replytool output to all clients viewing that session - Use hooks for rich event data (tool cards, usage stats)
- Maintain history buffer for late-joining clients
Chat Platform Bot
Chat App ↔ Bot API ↔ channel-plugin (MCP) ↔ Claude Code
- Plugin polls platform API for new messages, pushes as channel notifications
- Uses reply tool to send responses back via platform API
- Gate on
message.from.id, notmessage.chat.id
CI/CD Monitor
CI System → Webhook → channel-plugin → Claude Code
- One-way channel receives build failures, test results
- Claude investigates and can create commits/PRs
- No reply tool needed
Multi-Client Session Sharing
Multiple clients share one Claude session: use --permission-mode acceptEdits with permission relay, and all clients see the same state and can approve/reject tool calls.
Comparison: Channels vs Other Approaches
| Feature | claude -p |
Agent SDK | Channels | Remote Control |
|---|---|---|---|---|
| Quota pool | $20 SDK credit | $20 SDK credit | Main subscription | Main subscription |
| Session model | One-shot subprocess | SDK-managed | Long-lived interactive | Long-lived interactive |
| Event detail | stream-json (full) | Typed messages (full) | Reply+status (simple) | Terminal UI only |
| Custom UI | Parse NDJSON | SDK callbacks | WebSocket/HTTP bridge | claude.ai only |
| Permissions | dontAsk flags | canUseTool callback | Channel relay | Terminal prompt |
| Root bypass | Blocked v2.1.100+ | Works in SDK | N/A (interactive) | N/A |
| Multi-turn | --resume flag | sessionId option | Native (interactive) | Native |
Common Pitfalls
- Wrong notification method. Must be
notifications/claude/channelwith{ content, meta }, NOTnotifications/message. - No reply tool for two-way. Without a reply tool, Claude acts on notifications but your client gets no feedback.
- No sender gating. Anyone who can reach your endpoint can inject text into Claude. Gate on sender ID.
- Forgetting
instructions. Without theinstructionsstring, Claude doesn't know what<channel>tags to expect or how to reply. - Quota confusion.
claude --channels= interactive quota (generous).claude -p --mcp-config= SDK credit (limited). The--channelsflag is what routes to the better pool. - Meta key hyphens. Meta keys must be identifiers (letters, digits, underscores). Hyphens and special characters are silently dropped from
<channel>tag attributes.
Troubleshooting
- "not on approved allowlist": Use
--dangerously-load-development-channels server:nameorplugin:name@marketplace. - Session lock on resume: Remove stale
last-promptentries from<CLAUDE_HOME>/projects/<encoded-cwd>/<session-id>.jsonl. - dontAsk silently blocking:
dontAskdenies unauthorised tools. UseacceptEditsfor channels. - Hooks not firing: Use
127.0.0.1(notlocalhost), check JSON syntax (no trailing commas), respond within 10 seconds. - "blocked by org policy": Team/Enterprise admin must enable channels in org settings.
Key Docs
- Channels reference — the canonical protocol spec
- Channels overview
- Hooks reference
- MCP guide
- Plugin marketplace
- Remote Control
- Model Context Protocol