A channel is an MCP server that pushes events into your running Claude Code session. Your CI fails, your Sentry alert fires, your phone sends a DM — the message lands in the session you already have open, against the files Claude already has in context, and Claude reacts. It’s the inverse of an MCP tool, which Claude pulls on demand.
This is the webhook-receiver pattern, end to end, plus the security model and the rough edges. Channels require Claude Code v2.1.80+, claude.ai or Console API auth (not Bedrock/Vertex/Foundry), and Team/Enterprise orgs need an admin to enable them. Treat the protocol as research preview — the --channels flag and message shape may shift.
1. Pick your shape: existing plugin or webhook receiver
Telegram, Discord, iMessage, and a localhost demo (fakechat) ship as official plugins. If you want a chat bridge, install one of those. The pattern below builds a webhook receiver from scratch — for non-chat sources like Sentry, PagerDuty, GitHub, or your CI pipeline that can POST to an HTTP endpoint.
The trade-off is plumbing: the official plugins handle pairing flows and sender allowlists for you. A webhook receiver is ~50 lines of TypeScript but you write the security yourself.
2. Write the channel server
A channel is just an MCP server with one extra capability flag. This is the entire server for receiving Sentry-shaped webhooks:
#!/usr/bin/env bun
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
const ALLOWED_TOKEN = process.env.SENTRY_WEBHOOK_TOKEN
if (!ALLOWED_TOKEN) {
throw new Error('SENTRY_WEBHOOK_TOKEN is required — refusing to start with empty auth')
}
const mcp = new Server(
{ name: 'sentry-alerts', version: '0.0.1' },
{
capabilities: { experimental: { 'claude/channel': {} } },
instructions:
'Sentry alerts arrive as <channel source="sentry-alerts" severity="..." event_id="...">. ' +
'They are one-way: read, investigate, and report findings in chat. No reply expected.',
},
)
await mcp.connect(new StdioServerTransport())
Bun.serve({
port: 8789,
hostname: '127.0.0.1',
async fetch(req) {
if (req.method !== 'POST') return new Response('method not allowed', { status: 405 })
if (req.headers.get('X-Sentry-Token') !== ALLOWED_TOKEN) {
return new Response('forbidden', { status: 403 })
}
const payload = await req.json() as {
event: { event_id: string; level: string; title: string; web_url: string }
}
await mcp.notification({
method: 'notifications/claude/channel',
params: {
content: `${payload.event.title}\n${payload.event.web_url}`,
meta: {
severity: payload.event.level,
event_id: payload.event.event_id,
},
},
})
return new Response('ok')
},
})
What’s happening:
capabilities.experimental['claude/channel']: {}is the only thing that distinguishes this from an ordinary MCP server. Its presence tells Claude Code to register a notification listener.- The server connects to Claude Code over stdio (Claude Code spawns it as a subprocess, same as any MCP server).
- Bun listens on
127.0.0.1:8789— localhost only. Anything that needs to reach this from the internet has to go through a tunnel you set up (e.g. an ngrok-style relay or a Cloudflare Tunnel) — never bind0.0.0.0for a webhook receiver. X-Sentry-Tokenis checked before emitting anything. The shared secret comes from theSENTRY_WEBHOOK_TOKENenv var. No token check, no notification.mcp.notification()with methodnotifications/claude/channelpushes the event.contentbecomes the body of a<channel>tag in Claude’s context; eachmetakey becomes an attribute.
The event arrives in Claude’s session looking like:
<channel source="sentry-alerts" severity="error" event_id="abc123">
TypeError: cannot read property 'foo' of undefined
https://sentry.example.com/issues/9876
</channel>
Claude sees that, knows from the instructions string what it means, and acts.
3. Register the server and start the session
Add it to the project’s .mcp.json (or your user-level ~/.claude.json if you want it everywhere):
{
"mcpServers": {
"sentry-alerts": { "command": "bun", "args": ["./channels/sentry.ts"] }
}
}
During the research preview, custom channels aren’t on the official allowlist, so launch with the development flag:
SENTRY_WEBHOOK_TOKEN="$(openssl rand -hex 32)" \
claude --dangerously-load-development-channels server:sentry-alerts
Claude Code spawns sentry.ts as a subprocess. The HTTP listener starts on port 8789 automatically.
If your channel doesn’t bind, two diagnostics:
/mcpin the session shows the server’s status. “Failed to connect” usually means an import or runtime error in the script — check~/.claude/debug/<session-id>.txtfor the stderr trace.lsof -i :8789shows what’s listening. If a stale process from an earlier session is holding the port, kill it before restarting.
4. Wire Sentry to it
Sentry → Settings → Integrations → Internal Integrations → Create. Webhook URL points at your tunnel (https://your-tunnel.example/), custom header X-Sentry-Token: <the random token from step 3>. Test with curl first to make sure the local end works:
curl -X POST http://127.0.0.1:8789/ \
-H "X-Sentry-Token: $SENTRY_WEBHOOK_TOKEN" \
-H "Content-Type: application/json" \
-d '{"event":{"event_id":"test","level":"error","title":"Test alert","web_url":"https://example.com"}}'
If the message lands in your Claude Code session as a <channel> tag, the wiring works. Then point Sentry at the public end of your tunnel and trigger a real alert.
Two-way: replying back to Telegram
For chat platforms, you usually want Claude to reply through the same channel. That requires three additions to the server above: tools: {} in the capabilities, a reply tool registered with ListToolsRequestSchema/CallToolRequestSchema, and an updated instructions string telling Claude to use it. The Telegram, Discord, and iMessage plugins are full reference implementations — read the source under claude-plugins-official/external_plugins/.
The harder problem with two-way channels is permission prompts. If Claude tries to run Bash while you’re away from the terminal, the local approval dialog opens and the session waits. Channels that declare the claude/channel/permission capability — added in v2.1.81 — can relay the prompt to your phone. Your reply (yes <id> or no <id>) lets Claude continue. Relay covers tool-use approvals (Bash, Write, Edit); it explicitly does not cover project trust dialogs or MCP server consent prompts, which still require local approval. Only declare the capability if your channel authenticates the sender — anyone who can reply through the channel can approve tool use.
Footguns
Gate on sender, not chat. The biggest mistake people make with chat-platform channels is checking message.chat.id (the room) instead of message.from.id (the human). In a Telegram group with your bot in it, gating on chat means anyone in the group can inject text into your Claude Code session. The official plugins all gate on sender. If you build your own chat channel, do the same.
Permission relay grants remote tool approval, not just chat. A channel that declares claude/channel/permission can approve any Bash, Write, or Edit prompt that opens. The approved tool runs as your local Claude Code process, with whatever shell and filesystem authority that user has. If your sender allowlist is wrong, or the platform you’re using authenticates weakly, anyone who can DM your bot can approve rm -rf ~/. The doc says it directly: “anyone who can reply through the channel can approve or deny tool use in your session.” Permission relay is the strictest authentication boundary in the whole system; only opt in after you trust your gating code.
The session has to be running. Channels deliver events only while the session is open. Close the terminal, lose the laptop lid, log out — events are dropped. For “always-on” use, run Claude in a persistent terminal multiplexer (tmux, screen) on a machine that stays awake. If you want a workflow that fires on its own infrastructure regardless of your laptop, that’s a Routine, not a channel.
The tunnel is your auth boundary, not the listener. Your channel binds 127.0.0.1 and accepts traffic via whatever tunnel you set up. The tunnel terminates TLS; your listener does not. The shared-secret header (or whatever auth you implement) is the entire boundary between the public internet and your running Claude session. Don’t co-locate it with other unrelated origins on the same tunnel cert without thinking about it.
Permission prompts pause everything. Without the relay capability, a tool-use dialog stops the session until you tap a key in the terminal. The Sentry webhook fires, Claude investigates, tries to run a script, and waits forever for an approval that’s never coming because you’re asleep. For unattended channel-driven workflows you want one of: a relay channel, --dangerously-skip-permissions (only on a sandboxed machine), or a prompt that explicitly tells Claude not to run anything that needs approval.
The research preview means breaking changes. The --channels flag, the notification method names, the permission relay schema — all of these are explicitly allowed to shift during the preview. Pin a version of Claude Code if you’re shipping channel infrastructure to production, and watch the release notes. Don’t build a custom channel as the load-bearing piece of an on-call rotation today.
When NOT to use this
- The work needs to fire when your machine is off. Channels need a running session. Use Routines for cloud-resident scheduled or webhook-triggered work.
- You want Claude to query the system, not the system to push. That’s a regular MCP tool. Channels are for pushed events; MCP tools are for pulled queries. If your alert source is queryable, give Claude the query tool and tell it when to check.
- You only want to drive an existing session from elsewhere. Remote Control (drive your local session from claude.ai or the mobile app) is the right primitive. Channels are for non-Claude sources injecting events; Remote Control is for you steering an existing session.
- You’re on Bedrock, Vertex, or Foundry. Channels require Anthropic auth. Cloud-provider customers should use Routines or build a custom integration that doesn’t depend on the channel protocol.
- Your team policy requires mTLS or signed payloads. The default pattern is a localhost listener with a shared-secret header. If your environment needs cryptographic request authentication, you have to layer it at the tunnel or in your channel server — the protocol doesn’t enforce it. Confirm the threat model before you wire it up.