Channels reference
Build an MCP server that pushes alerts, webhooks or chat messages into a Claude Code session, with reply tools, sender gating and permission relay.
A channel is an MCP server that injects events into a running Claude Code session, so Claude can react to things that happen outside the terminal: a failed deploy, a monitoring alert, a message from your phone. This page is the contract for building one. If you only want to use an existing channel (Telegram, Discord, iMessage or the fakechat demo), read Channels instead.
Note: Channels are a research preview. Team and Enterprise organisations have to switch them on explicitly; Channels covers the organisation controls.
Channels come in two shapes:
- One-way: forward alerts, webhooks or monitoring events for Claude to act on.
- Two-way: a chat bridge that also offers a reply tool so Claude can answer. A two-way channel with a trustworthy sender check can additionally opt in to permission relay, letting you approve or deny tool calls remotely.
How a channel fits together
A channel runs on the same machine as Claude Code. Claude Code launches it as a subprocess and talks to it over stdio, exactly like any local MCP server. The channel's job is to bridge some outside system into that stdio connection:
- Chat platforms usually work by polling the platform's API from your machine, so there is no public URL to expose.
- Webhooks work by listening on a local HTTP port that CI or monitoring tools POST to.
Your server must do three things:
- Declare the
claude/channelcapability, which makes Claude Code register a listener. - Send
notifications/claude/channelwhen something happens. - Connect over the stdio transport.
The only dependency is the @modelcontextprotocol/sdk npm package plus a JavaScript runtime. Node, Bun and Deno all work. The examples below use plain Node with TypeScript run through tsx, mostly to show that nothing ties you to Bun.
Worked example: a deploy alert channel
I wanted Claude to start investigating as soon as a preview deployment failed, without me copying logs across. The channel below listens on 127.0.0.1:9123; my deploy script POSTs a JSON body there when a build fails.
Set up the project
mkdir deploy-channel && cd deploy-channel
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D tsx
zod is only needed later for permission relay, but it saves a second install.
Write the server
// deploy-channel.ts
import http from 'node:http'
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
const mcp = new Server(
{ name: 'deploys', version: '0.1.0' },
{
capabilities: { experimental: { 'claude/channel': {} } },
instructions:
'Deployment failures arrive as <channel source="deploys" site="..." stage="...">. ' +
'Read the log excerpt, find the likely cause in this repo, and propose a fix. No reply is expected.',
},
)
await mcp.connect(new StdioServerTransport())
http
.createServer(async (req, res) => {
if (req.method !== 'POST') return res.writeHead(405).end()
let raw = ''
for await (const chunk of req) raw += chunk
const { site = 'unknown', stage = 'build', log = raw } = safeJson(raw)
await mcp.notification({
method: 'notifications/claude/channel',
params: { content: log, meta: { site, stage } },
})
res.writeHead(202).end('queued')
})
.listen(9123, '127.0.0.1')
function safeJson(text: string): Record<string, string> {
try { return JSON.parse(text) } catch { return {} }
}
Three things happen, in order. The Server is created with claude/channel under experimental capabilities, which is what makes it a channel, and with an instructions string that Claude receives as context when the server connects. The server connects over stdio. Finally an HTTP listener bound to localhost turns each POST into a channel notification, with content as the body and each meta entry as an attribute.
Register it
Add it to the project's .mcp.json (relative paths are fine there; in ~/.claude.json use absolute paths so it works from any project):
{
"mcpServers": {
"deploys": { "command": "npx", "args": ["tsx", "./deploy-channel.ts"] }
}
}
Try it
Custom channels are not on the approved allowlist during the preview, so start Claude Code with the development flag:
claude --dangerously-load-development-channels server:deploys
You will see a full-screen warning listing the development channels; choose I am using this for local development. The first time in this project you will also be asked to approve the new server from .mcp.json ("New MCP server found in this project"); choose Use this MCP server. Claude Code then launches your script itself; you never start it by hand. A dim notice under the banner confirms that messages from server:deploys inject directly into the session. If you see "blocked by org policy", an administrator needs to enable channels first.
From another terminal, fake a failure:
curl -s -X POST 127.0.0.1:9123 \
-d '{"site":"client-portal","stage":"build","log":"Type error: Property \"slug\" does not exist on type \"Post\" (app/blog/[slug]/page.tsx:14)"}'
Claude receives:
<channel source="deploys" site="client-portal" stage="build">
Type error: Property "slug" does not exist on type "Post" (app/blog/[slug]/page.tsx:14)
</channel>
Your terminal shows a one-line summary prefixed with ← deploys:, and Claude starts opening files.
If nothing arrives:
curlsucceeded but Claude saw nothing: run/mcp. Afailedstatus is usually an import or dependency error. Restart withclaude --debug --dangerously-load-development-channels server:deploysand read~/.claude/debug/<session-id>.txtfor the stack trace.curlreports connection refused: the port is not bound yet, or a stale process from an earlier session holds it.lsof -i :9123shows the culprit; kill it and restart the session.
For a fuller two-way reference implementation with a web UI and attachments, look at the fakechat server in the claude-plugins-official repository.
Testing during the research preview
Every channel must be on the approved allowlist to register. The development flag bypasses the allowlist for the entries you name, after a confirmation prompt. Entries are either a plugin or a bare server:
# a channel packaged in a plugin you are building
claude --dangerously-load-development-channels plugin:deploy-alerts@studio-plugins
# a server defined directly in .mcp.json
claude --dangerously-load-development-channels server:deploys
Points to know:
- It only works interactively, because the confirmation prompt needs a terminal. With
-por the Agent SDK the flag is ignored and the channel does not register. - The bypass applies only to the entries listed with this flag. Combining it with
--channelsdoes not extend the bypass to those entries. - It skips the allowlist only. The
channelsEnabledorganisation policy still applies. Do not use it to run channels from sources you do not trust.
Server options
Set these in the MCP Server constructor. instructions and capabilities.tools are standard MCP; the two claude/channel keys are Claude Code extensions.
| Option | Type | Meaning |
|---|---|---|
capabilities.experimental['claude/channel'] | object | Required, always {}. Its presence registers the listener |
capabilities.experimental['claude/channel/permission'] | object or false | Optional. {} declares that the channel can receive permission relay requests. Omit or set false to opt out (before v2.1.234, false was wrongly treated as declared) |
capabilities.tools | object | Two-way channels only, always {}, so Claude Code discovers your reply tool |
instructions | string | Strongly recommended. Delivered to Claude as context on connect. Explain what events look like, what each tag attribute means, whether to reply, and if so with which tool and which attribute to pass back |
Leave out capabilities.tools for a one-way channel.
Notification format
Send notifications/claude/channel with:
| Param | Type | Meaning |
|---|---|---|
content | string | Event body; becomes the body of the <channel> tag |
meta | Record<string, string> | Optional attributes on the tag, for routing context such as a chat id, sender or severity. Keys must be identifiers (letters, digits, underscores). Keys with hyphens or other characters are silently dropped |
The source attribute is added automatically from your server's name. So meta: { severity: 'p1', service: 'billing' } from a server called pager produces <channel source="pager" severity="p1" service="billing">.
Delivery semantics:
- No acknowledgement. Awaiting
mcp.notification()only tells you the message was written to stdio, not that Claude processed it. If the session did not load your server as a channel, or policy blocks it, events are dropped without any error reaching your server. If you need confirmation, track state yourself and give Claude a tool to report back. - Ordered and batched. Events queue into the session in order. Several arriving while Claude is busy are delivered together on the next turn and handled as a group. For genuinely independent streams, use separate sessions.
Adding a reply tool
A two-way channel exposes an ordinary MCP tool that Claude calls to send a message out. Nothing about it is channel-specific; you need:
tools: {}in the constructor capabilities.- Handlers that list the tool and execute it.
instructionstelling Claude when to call it and what to pass.
Here the deploy channel gains a post_update tool. For a self-contained demo it appends replies to outbox.log, which you can watch with tail -f; a real bridge would call Slack, Teams or similar.
import { appendFile } from 'node:fs/promises'
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'
// in the constructor:
// capabilities: { experimental: { 'claude/channel': {} }, tools: {} },
// instructions: 'Failures arrive as <channel source="deploys" thread="..." ...>. ' +
// 'When you have a diagnosis, call post_update with the thread from the tag.'
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'post_update',
description: 'Post a status update to the deploy thread that raised the alert',
inputSchema: {
type: 'object',
properties: {
thread: { type: 'string', description: 'Thread id from the channel tag' },
message: { type: 'string', description: 'Update to post' },
},
required: ['thread', 'message'],
},
},
],
}))
mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
if (req.params.name !== 'post_update') throw new Error(`unknown tool ${req.params.name}`)
const { thread, message } = req.params.arguments as { thread: string; message: string }
await appendFile('outbox.log', `[${new Date().toISOString()}] #${thread} ${message}\n`)
return { content: [{ type: 'text', text: 'posted' }] }
})
Register the handlers between creating the Server and calling connect(), and add a thread key to the meta you send (an incrementing counter is enough for testing) so Claude has something to pass back.
Gating inbound messages
An ungated channel is a prompt injection hole: anyone who can reach it can put words in front of Claude. Any channel attached to a chat platform or a reachable endpoint needs a sender check before it emits anything.
const trustedSenders = new Set(await loadTrustedSenders()) // your own allowlist file
async function onPlatformMessage(msg: IncomingChat) {
if (!trustedSenders.has(msg.author.id)) return // drop silently
await mcp.notification({
method: 'notifications/claude/channel',
params: { content: msg.text, meta: { chat_id: msg.conversation.id } },
})
}
Check the sender's identity, not the room's. In a group chat, gating on the conversation id would let anyone in an allowlisted group inject text.
The Telegram and Discord channels work this way, building their allowlist by pairing (see Channels). The iMessage channel instead detects your own addresses from the Messages database at start-up and admits those automatically, with other senders added by handle. Their source is in the claude-plugins-official repository if you want to study a full pairing flow.
Permission relay
Normally, when Claude wants to run a tool that needs approval, a dialog opens in the terminal and the session waits. A two-way channel can opt in to receive the same prompt at the same time and forward it to you elsewhere. Both stay live: answer in the terminal or remotely, and whichever arrives first is applied while the other closes.
Relay covers tool approvals such as Bash, Write and Edit. Project trust and MCP server consent dialogs never relay; they appear only locally.
From v2.1.234, permission requests only go to servers registered as channels for the session, so relay sits behind the same session opt-in and organisation controls as messages. The server must be opted in via --channels or the development flag and declare the permission capability.
The relay loop
- Claude Code creates a short request id and notifies your server.
- Your server sends the prompt and id to your chat app.
- The person replies yes or no with that id.
- Your inbound handler turns the reply into a verdict; Claude Code applies it only if the id matches an open request.
The local dialog stays open throughout. If someone answers at the terminal first, that answer wins and the remote request is dropped.
Request fields
Claude Code sends notifications/claude/channel/permission_request with four string params:
| Field | Meaning |
|---|---|
request_id | Five lowercase letters from a to z excluding l (so it cannot be misread as 1 or I on a phone). Include it in your prompt; Claude Code only accepts verdicts carrying an id it issued. The terminal dialog does not show it, so your handler is the only place to get it |
tool_name | The tool, e.g. Bash or Write |
description | A human-readable summary of this call, never the command itself. For Bash it is Claude's description of the command, or the constant Run shell command if the model gave none, which tells the approver nothing |
input_preview | The tool arguments as JSON-shaped display text, per top-level field. For Bash, the command; for Write, the path and content. Show it whenever you have room |
How these fields are cleaned before you receive them depends on the client version:
- v2.1.211 and later neutralise direction-override characters, invisible characters, and quote or angle-bracket lookalikes; collapse whitespace runs to a single space; and pass text whole up to 3,500 code points. Longer values arrive as the start and end around a counted elision marker, so the end of a long command still reaches the approver. In
input_previewthe 3,500 limit applies per top-level field and the JSON's own quotes are kept. Older clients passdescriptionraw and cutinput_previewto 200 UTF-16 units with an ellipsis. - v2.1.234 and later replace field values that cannot be serialised safely (circular or enormous structures) with
(value unserializable), keeping the key; and mask recognisable provider credentials as[REDACTED]. Masking can apply to key names as well as values, so a displayed key may differ from the real one. Spans containing shell syntax, path or URL characters are never masked, so the command, path or destination always remains visible. Secrets with no recognisable prefix, or spanning whitespace (a private key block, say), are not masked.
Masking does not change who receives the text: only servers you opted in. Treat both fields as untrusted unless you control every client.
The verdict
Send back notifications/claude/channel/permission with request_id (echoed) and behavior set to 'allow' or 'deny'. A verdict applies to that one call only.
Adding relay to a two-way channel
Only do this if your channel authenticates senders: anyone who can reply through it can approve tool use in your session.
1. Declare the capability:
capabilities: {
experimental: {
'claude/channel': {},
'claude/channel/permission': {},
},
tools: {},
},
2. Forward requests. setNotificationHandler dispatches on a zod literal for method, so the schema doubles as the routing key:
import { z } from 'zod'
const ApprovalRequest = z.object({
method: z.literal('notifications/claude/channel/permission_request'),
params: z.object({
request_id: z.string(),
tool_name: z.string(),
description: z.string(),
input_preview: z.string(),
}),
})
mcp.setNotificationHandler(ApprovalRequest, async ({ params }) => {
await postToChat(
[
`Approval needed: ${params.tool_name}`,
params.description,
params.input_preview,
`Answer "y ${params.request_id}" to allow or "n ${params.request_id}" to deny.`,
].join('\n'),
)
})
Always include input_preview if the medium allows it. For Bash, the description alone can be the uninformative Run shell command.
3. Catch verdicts before they reach Claude. In the inbound handler, after the sender check and before forwarding chat:
// y/yes/n/no, then a five-letter id from the a-k, m-z alphabet; case-insensitive for phone autocorrect
const VERDICT = /^\s*(y|yes|n|no)\s+([a-km-z]{5})\s*$/i
async function onPlatformMessage(msg: IncomingChat) {
if (!trustedSenders.has(msg.author.id)) return
const v = VERDICT.exec(msg.text)
if (v) {
await mcp.notification({
method: 'notifications/claude/channel/permission',
params: {
request_id: v[2].toLowerCase(),
behavior: v[1].toLowerCase().startsWith('y') ? 'allow' : 'deny',
},
})
return // a verdict is never forwarded as chat
}
await mcp.notification({
method: 'notifications/claude/channel',
params: { content: msg.text, meta: { chat_id: msg.conversation.id } },
})
}
Lower-case the captured id before sending it, since autocorrect likes to capitalise.
Replies that do not quite match fail safely, and the terminal dialog stays open either way:
- Wrong format (
approve, oryeswith no id): the regex misses and the text goes to Claude as an ordinary message. - Right format, unknown id: your server sends a verdict, Claude Code finds no open request with that id, and drops it silently.
Testing relay locally
Relay only fires when a permission dialog actually opens, so start the session with the development flag and press Shift+Tab until the status bar shows ⏸ manual mode on. In auto mode, the classifier would decide instead of you and no dialog would appear.
A convenient trigger is the channel's own reply tool: reading files usually needs no approval, but calling mcp__<server>__<tool> to reply does. When the dialog opens locally, the same request (with its five-letter id) should arrive through your channel; answer it remotely and watch the local dialog close and the tool run.
Packaging as a plugin
To share a channel, wrap it in a plugin and publish it through a marketplace. Users install with /plugin install and enable it per session with --channels plugin:<name>@<marketplace>. The plugin manifest's channels array (see the manifest reference) lets Claude Code prompt for configuration such as tokens when the plugin is enabled.
A channel in your own marketplace still needs --dangerously-load-development-channels, because only the channel plugins in claude-plugins-official are on the default allowlist (the community marketplace is not). If you work with an Anthropic partner contact, they can coordinate an official listing. On Team and Enterprise plans, an admin can instead put your plugin in the organisation's allowedChannelPlugins list, which replaces the default allowlist.