SpyBara
Go Premium

channels-reference.md 2026-03-20 21:05 UTC to 2026-03-21 18:03 UTC

3 added, 3 removed.

2026
Tue 31 21:09 Mon 30 21:13 Sat 28 18:04 Fri 27 21:09 Thu 26 21:07 Wed 25 21:08 Tue 24 18:15 Mon 23 21:08 Sun 22 18:04 Sat 21 18:03 Fri 20 21:05 Thu 19 06:17 Wed 18 18:16 Tue 17 21:10 Mon 16 21:10 Sat 14 03:44 Fri 13 21:07 Thu 12 21:07 Wed 11 03:43 Tue 10 03:43 Mon 9 21:06 Sat 7 03:37 Fri 6 06:10 Thu 5 06:12 Wed 4 21:06 Sun 1 06:10

Channels reference

Build an MCP server that pushes webhooks, alerts, and chat messages into a Claude Code session. Reference for the channel contract: capability declaration, notification events, reply tools, and sender gating.

A channel is an MCP server that pushes events into a Claude Code session so Claude can react to things happening outside the terminal.

You can build a one-way or two-way channel. One-way channels forward alerts, webhooks, or monitoring events for Claude to act on. Two-way channels like chat bridges also expose a reply tool so Claude can send messages back.

This page covers:

To use an existing channel instead of building one, see Channels. Telegram, Discord, and fakechat are included in the research preview.

Overview

A channel is an MCP server that runs on the same machine as Claude Code. Claude Code spawns it as a subprocess and communicates over stdio. Your channel server is the bridge between external systems and the Claude Code session:

  • Chat platforms (Telegram, Discord): your plugin runs locally and polls the platform's API for new messages. When someone DMs your bot, the plugin receives the message and forwards it to Claude. No URL to expose.
  • Webhooks (CI, monitoring): your server listens on a local HTTP port. External systems POST to that port, and your server pushes the payload to Claude.
Architecture diagram showing external systems connecting to your local channel server, which communicates with Claude Code over stdio

What you need

The only hard requirement is the @modelcontextprotocol/sdk package and a Node.js-compatible runtime. Bun, Node, and Deno all work. The pre-built plugins in the research preview use Bun, but your channel doesn't have to.

Your server needs to:

  1. Declare the claude/channel capability so Claude Code registers a notification listener
  2. Emit notifications/claude/channel events when something happens
  3. Connect over stdio transport (Claude Code spawns your server as a subprocess)

The Server options and Notification format sections cover each of these in detail. See Example: build a webhook receiver for a full walkthrough.

During the research preview, custom channels aren't on the approved allowlist. Use --dangerously-load-development-channels to test locally. See Test during the research preview for details.

Example: build a webhook receiver

This walkthrough builds a single-file server that listens for HTTP requests and forwards them into your Claude Code session. By the end, anything that can send an HTTP POST, like a CI pipeline, a monitoring alert, or a curl command, can push events to Claude.

This example uses Bun as the runtime for its built-in HTTP server and TypeScript support. You can use Node or Deno instead; the only requirement is the MCP SDK.

1

Create the project

Create a new directory and install the MCP SDK:

mkdir webhook-channel && cd webhook-channel
bun add @modelcontextprotocol/sdk
2

Write the channel server

Create a file called webhook.ts. This is your entire channel server: it connects to Claude Code over stdio, and it listens for HTTP POSTs on port 8788. When a request arrives, it pushes the body to Claude as a channel event.

#!/usr/bin/env bun
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'

// Create the MCP server and declare it as a channel
const mcp = new Server(
{ name: 'webhook', version: '0.0.1' },
{
// this key is what makes it a channel — Claude Code registers a listener for it
capabilities: { experimental: { 'claude/channel': {} } },
// added to Claude's system prompt so it knows how to handle these events
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 (Claude Code spawns this process)
await mcp.connect(new StdioServerTransport())

// Start an HTTP server that forwards every POST to Claude
Bun.serve({
port: 8788,  // any open port works
// localhost-only: nothing outside this machine can POST
hostname: '127.0.0.1',
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
// each key becomes a tag attribute, e.g. <channel path="/" method="POST">
meta: { path: new URL(req.url).pathname, method: req.method },
},
})
return new Response('ok')
},
})

The file does three things in order:

  • Server configuration: creates the MCP server with claude/channel in its capabilities, which is what tells Claude Code this is a channel. The instructions string goes into Claude's system prompt: tell Claude what events to expect, whether to reply, and how to route replies if it should.
  • Stdio connection: connects to Claude Code over stdin/stdout. This is standard for any MCP server: Claude Code spawns it as a subprocess.
  • HTTP listener: starts a local web server on port 8788. Every POST body gets forwarded to Claude as a channel event via mcp.notification(). The content becomes the event body, and each meta entry becomes an attribute on the <channel> tag. The listener needs access to the mcp instance, so it runs in the same process. You could split it into separate modules for a larger project.
3

Register your server with Claude Code

Add the server to your MCP config so Claude Code knows how to start it. For a project-level .mcp.json in the same directory, use a relative path. For user-level config in ~/.claude.json, use the full absolute path so the server can be found from any project:

{
"mcpServers": {
"webhook": { "command": "bun", "args": ["./webhook.ts"] }
}
}

Claude Code reads your MCP config at startup and spawns each server as a subprocess.

4

Test it

During the research preview, custom channels aren't on the allowlist, so start Claude Code with the development flag:

claude --dangerously-load-development-channels server:webhook

When Claude Code starts, it reads your MCP config, spawns your webhook.ts as a subprocess, and the HTTP listener starts automatically on the port you configured (8788 in this example). You don't need to run the server yourself.

If you see "blocked by org policy," your Team or Enterprise admin needs to enable channels first.

In a separate terminal, simulate a webhook by sending an HTTP POST with a message to your server. This example sends a CI failure alert to port 8788 (or whichever port you configured):

curl -X POST localhost:8788 -d "build failed on main: https://ci.example.com/run/1234"

The payload arrives in your Claude Code session as a <channel> tag:

<channel source="webhook" path="/" method="POST">build failed on main: https://ci.example.com/run/1234</channel>

In your Claude Code terminal, you'll see Claude receive the message and start responding: reading files, running commands, or whatever the message calls for. This is a one-way channel, so Claude acts in your session but doesn't send anything back through the webhook. To add replies, see Expose a reply tool.

The fakechat server extends this pattern with a web UI, file attachments, and a reply tool for two-way chat.

Test during the research preview

During the research preview, every channel must be on the approved allowlist to register. The development flag bypasses the allowlist for specific entries after a confirmation prompt. This example shows both entry types:

# Testing a plugin you're developing
claude --dangerously-load-development-channels plugin:yourplugin@yourmarketplace

# Testing a bare .mcp.json server (no plugin wrapper yet)
claude --dangerously-load-development-channels server:webhook

The bypass is per-entry. Combining this flag with --channels doesn't extend the bypass to the --channels entries. During the research preview, the approved allowlist is Anthropic-curated, so your channel stays on the development flag while you build and test.

Server options

A channel sets these options in the Server constructor. The instructions and capabilities.tools fields are standard MCP; capabilities.experimental['claude/channel'] is the channel-specific addition:

Field Type Description
capabilities.experimental['claude/channel'] object Required. Always {}. Presence registers the notification listener.
capabilities.tools object Two-way only. Always {}. Standard MCP tool capability. See Expose a reply tool.
instructions string Recommended. Added to Claude's system prompt. Tell Claude what events to expect, what the <channel> tag attributes mean, whether to reply, and if so which tool to use and which attribute to pass back (like chat_id).

To create a one-way channel, omit capabilities.tools. This example shows a two-way setup with all three options set:

import { Server } from '@modelcontextprotocol/sdk/server/index.js'

const mcp = new Server(
  { name: 'your-channel', version: '0.0.1' },
  {
    capabilities: {
      experimental: { 'claude/channel': {} },  // registers the channel listener
      tools: {},  // omit for one-way channels
    },
    // added to Claude's system prompt so it knows how to handle your events
    instructions: 'Messages arrive as <channel source="your-channel" ...>. Reply with the reply tool.',
  },
)

To push an event, call mcp.notification() with method notifications/claude/channel. The params are in the next section.

Notification format

Your server emits notifications/claude/channel with two params:

Field Type Description
content string The event body. Delivered as the body of the <channel> tag.
meta Record<string, string> Optional. Each entry becomes an attribute on the <channel> tag for routing context like chat ID, sender name, or alert severity. Keys must be identifiers: letters, digits, and underscores only. Keys containing hyphens or other characters are silently dropped.

Your server pushes events by calling mcp.notification() on the Server instance. This example pushes a CI failure alert with two meta keys:

await mcp.notification({
  method: 'notifications/claude/channel',
  params: {
    content: 'build failed on main: https://ci.example.com/run/1234',
    meta: { severity: 'high', run_id: '1234' },
  },
})

The event arrives in Claude's context wrapped in a <channel> tag. The source attribute is set automatically from your server's configured name:

<channel source="your-channel" severity="high" run_id="1234">
build failed on main: https://ci.example.com/run/1234
</channel>

Expose a reply tool

If your channel is two-way, like a chat bridge rather than an alert forwarder, expose a standard MCP tool that Claude can call to send messages back. Nothing about the tool registration is channel-specific. A reply tool has three components:

  1. A tools: {} entry in your Server constructor capabilities so Claude Code discovers the tool
  2. Tool handlers that define the tool's schema and implement the send logic
  3. An instructions string in your Server constructor that tells Claude when and how to call the tool

To add these to the webhook receiver above:

1

Enable tool discovery

In your Server constructor in webhook.ts, add tools: {} to the capabilities so Claude Code knows your server offers tools:

capabilities: {
experimental: { 'claude/channel': {} },
tools: {},  // enables tool discovery
},
2

Register the reply tool

Add the following to webhook.ts. The import goes at the top of the file with your other imports; the two handlers go between the Server constructor and mcp.connect(). This registers a reply tool that Claude can call with a chat_id and text:

// Add this import at the top of webhook.ts
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'

// Claude queries this at startup to discover what tools your server offers
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [{
name: 'reply',
description: 'Send a message back over this channel',
// inputSchema tells Claude what arguments to pass
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'],
},
}],
}))

// Claude calls this when it wants to invoke a tool
mcp.setRequestHandler(CallToolRequestSchema, async req => {
if (req.params.name === 'reply') {
const { chat_id, text } = req.params.arguments as { chat_id: string; text: string }
// your platform's send API
await yourPlatform.send(chat_id, text)
return { content: [{ type: 'text', text: 'sent' }] }
}
throw new Error(`unknown tool: ${req.params.name}`)
})
3

Update the instructions

Update the instructions string in your Server constructor so Claude knows to route replies back through the tool. This example tells Claude to pass chat_id from the inbound tag:

instructions: 'Messages arrive as <channel source="webhook" chat_id="...">. Reply with the reply tool, passing the chat_id from the tag.'

Here's the complete webhook.ts with two-way support, combining the one-way receiver from the walkthrough with the reply tool additions:

#!/usr/bin/env bun
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'

const mcp = new Server(
  { name: 'webhook', version: '0.0.1' },
  {
    capabilities: {
      experimental: { 'claude/channel': {} },
      tools: {},
    },
    instructions: 'Messages arrive as <channel source="webhook" chat_id="...">. Reply with the reply tool, passing the chat_id from the tag.',
  },
)

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 }
    // your platform's send API — replace with your real integration
    console.error(`Reply to ${chat_id}: ${text}`)
    return { content: [{ type: 'text', text: 'sent' }] }
  }
  throw new Error(`unknown tool: ${req.params.name}`)
})

await mcp.connect(new StdioServerTransport())

let nextId = 1
Bun.serve({
  port: 8788,
  hostname: '127.0.0.1',
  async fetch(req) {
    const body = await req.text()
    const chat_id = String(nextId++)
    await mcp.notification({
      method: 'notifications/claude/channel',
      params: {
        content: body,
        meta: { chat_id, path: new URL(req.url).pathname, method: req.method },
      },
    })
    return new Response('ok')
  },
})

The fakechat server shows a more complete example with file attachments and message editing.

Gate inbound messages

An ungated channel is a prompt injection vector. Anyone who can reach your endpoint can put text in front of Claude. A channel listening to a chat platform or a public endpoint needs a real sender check before it emits anything.

Check the sender against an allowlist before calling mcp.notification(). This example drops any message from a sender not in the set:

const allowed = new Set(loadAllowlist())  // from your access.json or equivalent

// inside your message handler, before emitting:
if (!allowed.has(message.from.id)) {  // sender, not room
  return  // drop silently
}
await mcp.notification({ ... })

Gate on the sender's identity, not the chat or room identity: message.from.id in the example, not message.chat.id. In group chats, these differ, and gating on the room would let anyone in an allowlisted group inject messages into the session.

The Telegram and Discord channels gate on a sender allowlist the same way. They bootstrap the list by pairing: the user DMs the bot, the bot replies with a pairing code, the user approves it in their Claude Code session, and their platform ID is added. See either implementation for the full pairing flow.

Package as a plugin

To make your channel installable and shareable, wrap it in a plugin and publish it to a marketplace. Users install it with /plugin install, then enable it per session with --channels plugin:<name>@<marketplace>.

A channel published to your own marketplace still needs --dangerously-load-development-channels to run, since it isn't on the approved allowlist. To get it added, submit it to the official marketplace. Channel plugins go through security review before being approved.

See also

  • Channels to install and use Telegram, Discord, or the fakechat demo, and to enable channels for a Team or Enterprise org
  • Working channel implementations for complete server code with pairing flows, reply tools, and file attachments
  • MCP for the underlying protocol that channel servers implement
  • Plugins to package your channel so users can install it with /plugin install