Claude
Skills
Sign in
Back

channels

Included with Lifetime
$97 forever

# Channels Reference

General

What this skill does

# Channels Reference

> Build MCP servers that push webhooks, alerts, and chat messages into a Claude Code session.
> Requires Claude Code v2.1.80+ (permission relay requires v2.1.81+).
> Research preview — requires claude.ai login; Console/API key auth not supported.

## What Is a Channel

A channel is an MCP server that pushes events into a Claude Code session so Claude can react to things happening outside the terminal. Claude Code spawns it as a subprocess and communicates over stdio.

**One-way channels**: Forward alerts, webhooks, monitoring events for Claude to act on.
**Two-way channels**: Also expose a reply tool so Claude can send messages back.
**Permission relay**: Trusted two-way channels can forward tool approval prompts to remote devices.

### Architecture

```
External System → Your Channel Server (local) ←stdio→ Claude Code Session
```

- **Chat platforms** (Telegram, Discord): Plugin polls platform API, forwards messages to Claude
- **Webhooks** (CI, monitoring): Server listens on local HTTP port, pushes POSTs to Claude

### Built-in Channels (Research Preview)

Telegram, Discord, iMessage, and fakechat are included. Custom channels require `--dangerously-load-development-channels` flag.

## Building a Channel

### Requirements

- `@modelcontextprotocol/sdk` package
- Node.js-compatible runtime (Bun, Node, Deno)
- stdio transport (Claude Code spawns as subprocess)

### Minimal One-Way Channel (Webhook Receiver)

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

const mcp = new Server(
  { name: 'webhook', version: '0.0.1' },
  {
    // This key makes it a channel — Claude Code registers a listener
    capabilities: { experimental: { 'claude/channel': {} } },
    // Added to Claude's system prompt
    instructions: 'Events from the webhook channel arrive as <channel source="webhook" ...>. Read them and act, no reply expected.',
  },
)

await mcp.connect(new StdioServerTransport())

// HTTP server forwards every POST to Claude
Bun.serve({
  port: 8788,
  hostname: '127.0.0.1',
  async fetch(req) {
    const body = await req.text()
    await mcp.notification({
      method: 'notifications/claude/channel',
      params: {
        content: body,
        meta: { path: new URL(req.url).pathname, method: req.method },
      },
    })
    return new Response('ok')
  },
})
```

### Registration (.mcp.json)

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

### Testing

```bash
# Start with development flag
claude --dangerously-load-development-channels server:webhook

# In another terminal, send a test event
curl -X POST localhost:8788 -d "build failed on main: https://ci.example.com/run/1234"
```

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

## Server Options

| Field | Type | Description |
|-------|------|-------------|
| `capabilities.experimental['claude/channel']` | `object` | **Required**. Always `{}`. Registers the notification listener. |
| `capabilities.experimental['claude/channel/permission']` | `object` | Optional. Enables permission relay for remote tool approval. |
| `capabilities.tools` | `object` | Two-way only. Always `{}`. Enables MCP tool discovery. |
| `instructions` | `string` | Recommended. Added to Claude's system prompt. Describe events, reply behavior, and routing. |

## Notification Format

Push events via `mcp.notification()` with method `notifications/claude/channel`:

| Field | Type | Description |
|-------|------|-------------|
| `content` | `string` | Event body — becomes body of `<channel>` tag |
| `meta` | `Record<string, string>` | Optional. Each entry becomes a tag attribute (e.g., `chat_id`, `severity`). Keys: letters, digits, underscores only. |

```ts
await mcp.notification({
  method: 'notifications/claude/channel',
  params: {
    content: 'build failed on main',
    meta: { severity: 'high', run_id: '1234' },
  },
})
// Arrives as: <channel source="your-channel" severity="high" run_id="1234">build failed on main</channel>
```

## Two-Way Channels: Reply Tool

Add a reply tool so Claude can send messages back:

1. Add `tools: {}` to capabilities
2. Register `ListToolsRequestSchema` and `CallToolRequestSchema` handlers
3. Update `instructions` to tell Claude when/how to reply

```ts
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(`Reply to ${chat_id}: ${text}`)
    return { content: [{ type: 'text', text: 'sent' }] }
  }
  throw new Error(`unknown tool: ${req.params.name}`)
})
```

## Sender Gating (Security)

**An ungated channel is a prompt injection vector.** Always check sender identity before emitting notifications:

```ts
const allowed = new Set(loadAllowlist())

// Gate on sender identity, NOT room/chat identity
if (!allowed.has(message.from.id)) {
  return  // drop silently
}
await mcp.notification({ ... })
```

Gate on `message.from.id`, not `message.chat.id` — in group chats these differ, and gating on room would let anyone in an allowlisted group inject messages.

### Pairing Flows
- **Telegram/Discord**: User DMs bot → bot sends pairing code → user approves in Claude Code → platform ID added to allowlist
- **iMessage**: Detects user's own addresses from Messages DB at startup, auto-allows

## Permission Relay

Requires Claude Code v2.1.81+. Lets remote users approve/deny tool use from another device.

### How It Works

1. Claude Code generates 5-letter request ID, notifies your server
2. Server forwards prompt + ID to chat app
3. User replies `yes <id>` or `no <id>`
4. Server parses reply into verdict notification back to Claude Code

Local terminal dialog stays open — first answer (local or remote) wins.

### Request ID Format
Five lowercase letters from `a-z` excluding `l` (avoids confusion with `1`/`I`).

### Permission Request Fields

| Field | Description |
|-------|-------------|
| `request_id` | Five-letter ID to echo in verdict |
| `tool_name` | Tool name (e.g., `Bash`, `Write`) |
| `description` | Human-readable summary of tool call |
| `input_preview` | Tool args as JSON, truncated to 200 chars |

### Implementation

```ts
import { z } from 'zod'

// 1. Declare capability
capabilities: {
  experimental: {
    'claude/channel': {},
    'claude/channel/permission': {},  // opt in
  },
  tools: {},
},

// 2. Handle incoming permission requests
const PermissionRequestSchema = 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(PermissionRequestSchema, async ({ params }) => {
  send(
    `Claude wants to run ${params.tool_name}: ${params.description}\n\n` +
    `Reply "yes ${params.request_id}" or "no ${params.request_id}"`,
  )
})

// 3. Parse verdict from inbound messages
const PERMISSION_REPLY_RE = /^\s*(y|yes|n|no)\s+([a-km-z]{5})\s*$/i

const m = PERMISSION_REPLY_RE.exec(body)
if (m) {
  await mcp.notification({
    method: 'notifications/claude/channel/permission',
    params: {
      request_id: m[2].toLowerCase(),
      behavior: m[1].t

Related in General