通俗版 Claude Code 文档

结构照原 docs,内容改成真能照着做的人话版。

查看原始文档 目录顺序与官方保持一致

参考

Channels reference

Channels reference 这一页讲的,就是 Channels reference 这件事在 Claude Code 里到底怎么用。

页面信息

对应原页

Channels reference

页面性质

第三方中文解释页

使用建议

先看人话解释,再对照原页命令和代码

这页不是官方原文,而是顺着官方文档结构做的中文解释版。命令、参数、配置名这些硬东西尽量保留,解释部分则尽量讲成人能照着做的话。

如果你碰到特别敏感的配置、权限或企业环境差异,最好顺手点上面的“查看原始文档”再核一遍。

这一页先讲明白

这页主要讲 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, sender gating, and permission relay.

你可以把它当成"Reference"这块里专门管这一摊事的说明书。

你可以把"Channels reference"理解成 Reference 这一栏里的一把专门工具。这页不是让你背书,而是教你什么时候该把这把工具拿出来。

原文这页大多会按 Overview、What you need、Example: build a webhook receiver、Test during the research preview 这些环节往下讲。

翻成人话,大概就是:Example: build a webhook receiver

第一,先别一上来全开全配。先按最小一步试通,确认没跑偏,再继续往下加。

第二,命令、配置名、参数名这些硬东西尽量保留原样。人话解释是帮你听懂,不是帮你改关键字。

第三,照着原文这几个环节挨个过:Overview -> What you need -> Example: build a webhook receiver -> Test during the research preview。像下地先看水路、再试机器、再正式开干,一步一步最稳。

终端里敲

原页关键片段:Example: build a webhook receiver 1

这一段不是只让你理解意思,下面这条命令就是现在要跑的。

mkdir webhook-channel && cd webhook-channel
bun add @modelcontextprotocol/sdk
终端里敲

原页关键片段:Example: build a webhook receiver 2

这一段不是只让你理解意思,下面这条命令就是现在要跑的。

#!/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')
  },
})
改配置

原页关键片段:Example: build a webhook receiver 3

这一段说完,最后还得写到配置里才算真的生效。

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

原页关键片段:Test during the research preview

想把这条规矩固定住,就把下面这块老老实实写进去。

# 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
终端里敲

原页关键片段:Server options

这一段不是只让你理解意思,下面这条命令就是现在要跑的。

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.',
  },
)
终端里敲

原页关键片段:Notification format 1

先别急着往下翻,下面这条命令跑完,心里才有底。

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' },
  },
})
关键片段

原页关键片段:Notification format 2

这一段要真抓重点,通常就抓下面这块原文。

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

原页关键片段:Expose a reply tool 1

先看下面这块原始片段,等会儿再回头看解释会顺得多。

capabilities: {
  experimental: { 'claude/channel': {} },
  tools: {},  // enables tool discovery
},
终端里敲

原页关键片段:Expose a reply tool 2

这一段不是只让你理解意思,下面这条命令就是现在要跑的。

// 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 }
    // send() is your outbound: POST to your chat platform, or for local
    // testing the SSE broadcast shown in the full example below.
    send(`Reply to ${chat_id}: ${text}`)
    return { content: [{ type: 'text', text: 'sent' }] }
  }
  throw new Error(`unknown tool: ${req.params.name}`)
})
关键片段

原页关键片段:Expose a reply tool 3

先看下面这块原始片段,等会儿再回头看解释会顺得多。

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

原页关键片段:Gate inbound messages

这会儿轮到改配置了,字段名和关键字别自己乱换。

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({ ... })
关键片段

原页关键片段:Add relay to a chat bridge 1

先看下面这块原始片段,等会儿再回头看解释会顺得多。

capabilities: {
  experimental: {
    'claude/channel': {},
    'claude/channel/permission': {},  // opt in to permission relay
  },
  tools: {},
},

预留广告位

正文中段响应式广告 等你后面真接 AdSense,这里再放正式广告。

Documentation Index

这里不是让你背"Documentation Index"这个词,而是让你看它真干活时怎么使。

这里还牵扯作用域,意思就是这条规则到底管当前项目、你个人,还是只管这一趟会话。

Overview

看到这里,就把"Overview"当成一件真要上手的活来看。

这里还牵扯作用域,意思就是这条规则到底管当前项目、你个人,还是只管这一趟会话。

What you need

这一段不只是挂个标题,它是在说明"What you need"这一块到底负责什么。

看这段时要特别盯工具和权限边界,别为了省事一把全开。

Example: build a webhook receiver

别把这段只当成标题看,它其实是在给"Example: build a webhook receiver"划边界。

这里还牵扯作用域,意思就是这条规则到底管当前项目、你个人,还是只管这一趟会话。

终端里敲

Example: build a webhook receiver 1

这一段不是只让你理解意思,下面这条命令就是现在要跑的。

mkdir webhook-channel && cd webhook-channel
bun add @modelcontextprotocol/sdk
终端里敲

Example: build a webhook receiver 2

这一段不是只让你理解意思,下面这条命令就是现在要跑的。

#!/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')
  },
})
改配置

Example: build a webhook receiver 3

这一段说完,最后还得写到配置里才算真的生效。

{
  "mcpServers": {
    "webhook": { "command": "bun", "args": ["./webhook.ts"] }
  }
}
终端里敲

Example: build a webhook receiver 4

这一段不是只让你理解意思,下面这条命令就是现在要跑的。

claude --dangerously-load-development-channels server:webhook
终端里敲

Example: build a webhook receiver 5

这一段不是只让你理解意思,下面这条命令就是现在要跑的。

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

Example: build a webhook receiver 6

先看下面这块原始片段,等会儿再回头看解释会顺得多。

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

Test during the research preview

这一块主要是在说"Test during the research preview"真到手上该怎么用,哪里最容易踩坑。

看这段时要特别盯工具和权限边界,别为了省事一把全开。

改配置

Test during the research preview

想把这条规矩固定住,就把下面这块老老实实写进去。

# 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

Server options

看到这里,就把"Server options"当成一件真要上手的活来看。

看这段时要特别盯工具和权限边界,别为了省事一把全开。

终端里敲

Server options

这一段不是只让你理解意思,下面这条命令就是现在要跑的。

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.',
  },
)

Notification format

这一段主要是在把"Notification format"讲实,不是只摆个标题给你看。

如果你打算把外接能力往里挂,这里提到的 hooks、MCP、skills、memory 都要分清各自负责哪一摊。

终端里敲

Notification format 1

先别急着往下翻,下面这条命令跑完,心里才有底。

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' },
  },
})
关键片段

Notification format 2

这一段要真抓重点,通常就抓下面这块原文。

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

Expose a reply tool

这一段更像在讲判断条件,什么时候该上,什么时候先别急。把触发条件看清,比背标题更重要。

看这段时要特别盯工具和权限边界,别为了省事一把全开。

关键片段

Expose a reply tool 1

先看下面这块原始片段,等会儿再回头看解释会顺得多。

capabilities: {
  experimental: { 'claude/channel': {} },
  tools: {},  // enables tool discovery
},
终端里敲

Expose a reply tool 2

这一段不是只让你理解意思,下面这条命令就是现在要跑的。

// 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 }
    // send() is your outbound: POST to your chat platform, or for local
    // testing the SSE broadcast shown in the full example below.
    send(`Reply to ${chat_id}: ${text}`)
    return { content: [{ type: 'text', text: 'sent' }] }
  }
  throw new Error(`unknown tool: ${req.params.name}`)
})
关键片段

Expose a reply tool 3

先看下面这块原始片段,等会儿再回头看解释会顺得多。

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

Expose a reply tool 4

这一段不是只让你理解意思,下面这条命令就是现在要跑的。

#!/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'

// --- Outbound: write to any curl -N listeners on /events --------------------
// A real bridge would POST to your chat platform instead.
const listeners = new Set<(chunk: string) => void>()
function send(text: string) {
  const chunk = text.split('\n').map(l => `data: ${l}\n`).join('') + '\n'
  for (const emit of listeners) emit(chunk)
}

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 }
    send(`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',
  idleTimeout: 0,  // don't close idle SSE streams
  async fetch(req) {
    const url = new URL(req.url)

    // GET /events: SSE stream so curl -N can watch Claude's replies live
    if (req.method === 'GET' && url.pathname === '/events') {
      const stream = new ReadableStream({
        start(ctrl) {
          ctrl.enqueue(': connected\n\n')  // so curl shows something immediately
          const emit = (chunk: string) => ctrl.enqueue(chunk)
          listeners.add(emit)
          req.signal.addEventListener('abort', () => listeners.delete(emit))
        },
      })
      return new Response(stream, {
        headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' },
      })
    }

    // POST: forward to Claude as a channel event
    const body = await req.text()
    const chat_id = String(nextId++)
    await mcp.notification({
      method: 'notifications/claude/channel',
      params: {
        content: body,
        meta: { chat_id, path: url.pathname, method: req.method },
      },
    })
    return new Response('ok')
  },
})

Gate inbound messages

这一段主要是在把"Gate inbound messages"讲实,不是只摆个标题给你看。

这里还牵扯作用域,意思就是这条规则到底管当前项目、你个人,还是只管这一趟会话。

改配置

Gate inbound messages

这会儿轮到改配置了,字段名和关键字别自己乱换。

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({ ... })

Relay permission prompts

看到这里,就把"Relay permission prompts"当成一件真要上手的活来看。

这里还牵扯作用域,意思就是这条规则到底管当前项目、你个人,还是只管这一趟会话。

How relay works

看懂这里,后面出怪事时你心里会更有底。

这里还牵扯作用域,意思就是这条规则到底管当前项目、你个人,还是只管这一趟会话。

Permission request fields

这一段就是给你查规矩的,像看说明书那样一项项对着来。

看这段时要特别盯工具和权限边界,别为了省事一把全开。

Add relay to a chat bridge

看到这里,就把"Add relay to a chat bridge"当成一件真要上手的活来看。

这里还牵扯作用域,意思就是这条规则到底管当前项目、你个人,还是只管这一趟会话。

关键片段

Add relay to a chat bridge 1

先看下面这块原始片段,等会儿再回头看解释会顺得多。

capabilities: {
  experimental: {
    'claude/channel': {},
    'claude/channel/permission': {},  // opt in to permission relay
  },
  tools: {},
},
终端里敲

Add relay to a chat bridge 2

这一段不是只让你理解意思,下面这条命令就是现在要跑的。

import { z } from 'zod'

// setNotificationHandler routes by z.literal on the method field,
// so this schema is both the validator and the dispatch key
const PermissionRequestSchema = z.object({
  method: z.literal('notifications/claude/channel/permission_request'),
  params: z.object({
    request_id: z.string(),     // five lowercase letters, include verbatim in your prompt
    tool_name: z.string(),      // e.g. "Bash", "Write"
    description: z.string(),    // human-readable summary of this call
    input_preview: z.string(),  // tool args as JSON, truncated to ~200 chars
  }),
})

mcp.setNotificationHandler(PermissionRequestSchema, async ({ params }) => {
  // send() is your outbound: POST to your chat platform, or for local
  // testing the SSE broadcast shown in the full example below.
  send(
    `Claude wants to run ${params.tool_name}: ${params.description}\n\n` +
    // the ID in the instruction is what your inbound handler parses in Step 3
    `Reply "yes ${params.request_id}" or "no ${params.request_id}"`,
  )
})
终端里敲

Add relay to a chat bridge 3

这一段不是只让你理解意思,下面这条命令就是现在要跑的。

// matches "y abcde", "yes abcde", "n abcde", "no abcde"
// [a-km-z] is the ID alphabet Claude Code uses (lowercase, skips 'l')
// /i tolerates phone autocorrect; lowercase the capture before sending
const PERMISSION_REPLY_RE = /^\s*(y|yes|n|no)\s+([a-km-z]{5})\s*$/i

async function onInbound(message: PlatformMessage) {
  if (!allowed.has(message.from.id)) return  // gate on sender first

  const m = PERMISSION_REPLY_RE.exec(message.text)
  if (m) {
    // m[1] is the verdict word, m[2] is the request ID
    // emit the verdict notification back to Claude Code instead of chat
    await mcp.notification({
      method: 'notifications/claude/channel/permission',
      params: {
        request_id: m[2].toLowerCase(),  // normalize in case of autocorrect caps
        behavior: m[1].toLowerCase().startsWith('y') ? 'allow' : 'deny',
      },
    })
    return  // handled as verdict, don't also forward as chat
  }

  // didn't match verdict format: fall through to the normal chat path
  await mcp.notification({
    method: 'notifications/claude/channel',
    params: { content: message.text, meta: { chat_id: String(message.chat.id) } },
  })
}

Full example

别把这段只当成标题看,它其实是在给"Full example"划边界。

这里还牵扯作用域,意思就是这条规则到底管当前项目、你个人,还是只管这一趟会话。

终端里敲

Full example 1

这一段不是只让你理解意思,下面这条命令就是现在要跑的。

#!/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'
import { z } from 'zod'

// --- Outbound: write to any curl -N listeners on /events --------------------
// A real bridge would POST to your chat platform instead.
const listeners = new Set<(chunk: string) => void>()
function send(text: string) {
  const chunk = text.split('\n').map(l => `data: ${l}\n`).join('') + '\n'
  for (const emit of listeners) emit(chunk)
}

// Sender allowlist. For the local walkthrough we trust the single X-Sender
// header value "dev"; a real bridge would check the platform's user ID.
const allowed = new Set(['dev'])

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

// --- reply tool: Claude calls this to send a message back -------------------
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}`)
})

// --- permission relay: Claude Code (not Claude) calls this when a dialog opens
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}"`,
  )
})

await mcp.connect(new StdioServerTransport())

// --- HTTP on :8788: GET /events streams outbound, POST routes inbound -------
const PERMISSION_REPLY_RE = /^\s*(y|yes|n|no)\s+([a-km-z]{5})\s*$/i
let nextId = 1

Bun.serve({
  port: 8788,
  hostname: '127.0.0.1',
  idleTimeout: 0,  // don't close idle SSE streams
  async fetch(req) {
    const url = new URL(req.url)

    // GET /events: SSE stream so curl -N can watch replies and prompts live
    if (req.method === 'GET' && url.pathname === '/events') {
      const stream = new ReadableStream({
        start(ctrl) {
          ctrl.enqueue(': connected\n\n')  // so curl shows something immediately
          const emit = (chunk: string) => ctrl.enqueue(chunk)
          listeners.add(emit)
          req.signal.addEventListener('abort', () => listeners.delete(emit))
        },
      })
      return new Response(stream, {
        headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' },
      })
    }

    // everything else is inbound: gate on sender first
    const body = await req.text()
    const sender = req.headers.get('X-Sender') ?? ''
    if (!allowed.has(sender)) return new Response('forbidden', { status: 403 })

    // check for verdict format before treating as chat
    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].toLowerCase().startsWith('y') ? 'allow' : 'deny',
        },
      })
      return new Response('verdict recorded')
    }

    // normal chat: forward to Claude as a channel event
    const chat_id = String(nextId++)
    await mcp.notification({
      method: 'notifications/claude/channel',
      params: { content: body, meta: { chat_id, path: url.pathname } },
    })
    return new Response('ok')
  },
})
终端里敲

Full example 2

这一段不是只让你理解意思,下面这条命令就是现在要跑的。

claude --dangerously-load-development-channels server:webhook
终端里敲

Full example 3

这一段不是只让你理解意思,下面这条命令就是现在要跑的。

curl -N localhost:8788/events
终端里敲

Full example 4

这一段不是只让你理解意思,下面这条命令就是现在要跑的。

curl -d "list the files in this directory" -H "X-Sender: dev" localhost:8788
终端里敲

Full example 5

这一段不是只让你理解意思,下面这条命令就是现在要跑的。

curl -d "yes <id>" -H "X-Sender: dev" localhost:8788

Package as a plugin

这一段主要是在把"Package as a plugin"讲实,不是只摆个标题给你看。

这里还牵扯作用域,意思就是这条规则到底管当前项目、你个人,还是只管这一趟会话。

See also

这一段主要是在把"See also"讲实,不是只摆个标题给你看。

这里还牵扯作用域,意思就是这条规则到底管当前项目、你个人,还是只管这一趟会话。

照着做一遍

如果你不想来回翻,就先照这几步顺着做。

每做完一步就看一下结果,再决定要不要继续往下。

终端里敲

第 1 步:Example: build a webhook receiver 1

这一段不是只让你理解意思,下面这条命令就是现在要跑的。

mkdir webhook-channel && cd webhook-channel
bun add @modelcontextprotocol/sdk
终端里敲

第 2 步:Example: build a webhook receiver 2

这一段不是只让你理解意思,下面这条命令就是现在要跑的。

#!/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')
  },
})
改配置

第 3 步:Example: build a webhook receiver 3

这一段说完,最后还得写到配置里才算真的生效。

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

第 4 步:Test during the research preview

想把这条规矩固定住,就把下面这块老老实实写进去。

# 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

一眼看懂这一页

先把这页到底在讲什么看明白,再去碰具体命令和配置,最不容易绕晕。

Channels reference
   |
   v
这是 Reference 里的一摊要紧活
   |
   v
先弄懂,再下手

文末提醒

这站会按官方 docs 的导航和内容变化继续重生成,原站加页、删页、改页时,这里会跟着更新。

人话解释会尽量顺着原页往下讲,但命令、参数名、配置名这些硬东西还是保留原样,免得你抄过去跑不起来。