Bỏ qua để đến nội dung

Tham chiếu Channels

Bài viết được dịch tự động từ bài viết gốc, chưa được kiểm tra lại bởi con người. Chỉ những bài viết có dấu tick xanh cạnh tiêu đề là đã được kiểm tra.

Một channel là một MCP server đẩy sự kiện vào phiên Claude Code, để Claude phản ứng với những gì xảy ra bên ngoài terminal.

Bạn có thể xây channel một chiều hoặc hai chiều. Channel một chiều chuyển tiếp cảnh báo, webhook, hoặc sự kiện monitoring để Claude xử lý. Channel hai chiều như chat bridge còn expose một reply tool để Claude gửi tin nhắn ngược lại. Một channel có đường truyền người gửi đáng tin cậy có thể chọn tham gia relay permission prompt để bạn duyệt/từ chối tool use từ xa.

Trang này gồm:

Để dùng channel có sẵn thay vì tự xây, xem Channels. Telegram, Discord, iMessage, và fakechat đã có trong research preview.

Một channel là một MCP server chạy trên cùng máy với Claude Code. Claude Code spawn nó như một subprocess và giao tiếp qua stdio. Channel server của bạn là cầu nối giữa hệ thống bên ngoài và phiên Claude Code:

  • Chat platform (Telegram, Discord): plugin của bạn chạy local và poll API của platform để tìm tin nhắn mới. Khi ai đó DM bot, plugin nhận tin nhắn và chuyển tới Claude. Không cần expose URL.
  • Webhook (CI, monitoring): server của bạn lắng nghe trên một cổng HTTP local. Hệ thống bên ngoài POST vào cổng đó, và server đẩy payload tới Claude.

(Kiến trúc: hệ thống bên ngoài kết nối tới channel server local của bạn, server này giao tiếp với Claude Code qua stdio.)

Yêu cầu bắt buộc duy nhất là package @modelcontextprotocol/sdk và một runtime tương thích Node.js. Bun, Node, và Deno đều dùng được. Các plugin dựng sẵn trong research preview dùng Bun, nhưng channel của bạn không bắt buộc phải vậy.

Server của bạn cần:

  1. Khai báo capability claude/channel để Claude Code đăng ký listener cho notification
  2. Emit sự kiện notifications/claude/channel khi có gì đó xảy ra
  3. Kết nối qua stdio transport (Claude Code spawn server của bạn như subprocess)

Phần Tùy chọn ServerĐịnh dạng notification nói chi tiết từng mục. Xem Ví dụ: xây webhook receiver để có walkthrough đầy đủ.

Trong giai đoạn research preview, channel tùy chỉnh chưa nằm trong allowlist đã duyệt. Dùng --dangerously-load-development-channels để test local. Xem Test trong giai đoạn research preview để biết chi tiết.

Walkthrough này xây một server một file, lắng nghe HTTP request và chuyển tiếp vào phiên Claude Code của bạn. Sau khi xong, bất cứ thứ gì có thể gửi HTTP POST - CI pipeline, cảnh báo monitoring, hay lệnh curl - đều có thể đẩy sự kiện tới Claude.

Ví dụ này dùng Bun làm runtime vì có HTTP server và hỗ trợ TypeScript sẵn. Bạn có thể dùng Node hoặc Deno thay thế; yêu cầu duy nhất là MCP SDK.

Bước 1 - Tạo project

Các ví dụ relay permission ở phần sau import trực tiếp zod, nên cài luôn cùng MCP SDK. Tạo thư mục mới và cài cả hai:

Terminal window
mkdir webhook-channel && cd webhook-channel
bun add @modelcontextprotocol/sdk zod

Bước 2 - Viết channel server

Tạo file webhook.ts. Đây là toàn bộ channel server: kết nối tới Claude Code qua stdio, và lắng nghe HTTP POST trên cổng 8788. Khi có request tới, nó đẩy body tới Claude như một channel event.

webhook.ts
#!/usr/bin/env bun
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
// Tạo MCP server và khai báo nó là một channel
const mcp = new Server(
{ name: 'webhook', version: '0.0.1' },
{
// key này biến nó thành channel - Claude Code đăng ký listener cho nó
capabilities: { experimental: { 'claude/channel': {} } },
// được thêm vào system prompt của Claude để nó biết xử lý các sự kiện này thế nào
instructions: 'Events from the webhook channel arrive as <channel source="webhook" ...>. They are one-way: read them and act, no reply expected.',
},
)
// Kết nối tới Claude Code qua stdio (Claude Code spawn process này)
await mcp.connect(new StdioServerTransport())
// Khởi động HTTP server chuyển tiếp mọi POST tới Claude
Bun.serve({
port: 8788, // cổng nào mở cũng dùng được
// chỉ localhost: không gì bên ngoài máy này có thể POST
hostname: '127.0.0.1',
async fetch(req) {
const body = await req.text()
await mcp.notification({
method: 'notifications/claude/channel',
params: {
content: body, // trở thành body của tag <channel>
// mỗi key trở thành một attribute của tag, ví dụ <channel path="/" method="POST">
meta: { path: new URL(req.url).pathname, method: req.method },
},
})
return new Response('ok')
},
})

File này làm ba việc theo thứ tự:

  • Cấu hình server: tạo MCP server với claude/channel trong capabilities, đây là thứ báo cho Claude Code biết đây là một channel. Chuỗi instructions đi vào system prompt của Claude: cho biết event nào sẽ tới, có cần reply không, và route reply thế nào nếu có.
  • Kết nối stdio: kết nối tới Claude Code qua stdin/stdout. Đây là chuẩn cho mọi MCP server: Claude Code spawn nó như subprocess.
  • HTTP listener: khởi động web server local trên cổng 8788. Mỗi POST body được chuyển tới Claude như một channel event qua mcp.notification(). content trở thành body sự kiện, và mỗi entry trong meta trở thành một attribute trên tag <channel>. Listener cần truy cập instance mcp, nên nó chạy trong cùng process. Bạn có thể tách thành các module riêng cho project lớn hơn.

Bước 3 - Đăng ký server với Claude Code

Thêm server vào MCP config để Claude Code biết cách khởi động nó. Với .mcp.json cấp project trong cùng thư mục, dùng đường dẫn tương đối. Với config cấp user trong ~/.claude.json, dùng đường dẫn tuyệt đối đầy đủ để server tìm được từ bất kỳ project nào:

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

Claude Code đọc MCP config lúc khởi động và spawn từng server như một subprocess.

Bước 4 - Test

Trong giai đoạn research preview, channel tùy chỉnh chưa nằm trong allowlist, nên khởi động Claude Code với development flag:

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

Claude Code hiện trước một dialog cảnh báo toàn màn hình liệt kê các development channel bạn đang nạp. Chọn I am using this for local development để tiếp tục, hoặc Exit để thoát.

Lần đầu bạn khởi động phiên trong project này, Claude Code cũng hỏi xin đồng ý trước khi dùng server mới từ .mcp.json. Dialog báo “New MCP server found in this project: webhook”. Chọn Use this MCP server để tiếp tục.

Sau khi bạn đồng ý, Claude Code spawn webhook.ts như một subprocess, và HTTP listener tự khởi động trên cổng bạn cấu hình, ở ví dụ này là 8788. Bạn không cần tự chạy server.

Một dòng thông báo mờ dưới banner khởi động xác nhận channel đã đăng ký: Channels (experimental) messages from server:webhook inject directly in this session · restart without --dangerously-load-development-channels to stop.

Nếu bạn thấy “blocked by org policy”, admin tổ chức của bạn cần bật channels trước.

Trong một terminal riêng, mô phỏng webhook bằng cách gửi HTTP POST kèm tin nhắn tới server. Ví dụ này gửi cảnh báo CI failure tới cổng 8788 (hoặc cổng bạn đã cấu hình):

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

Payload tới context của Claude dưới dạng tag <channel>:

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

Terminal của bạn render sự kiện thành một dòng tóm tắt, ← webhook: build failed on main: https://ci.example.com/run/1234, thay vì tag thô. Sau đó bạn sẽ thấy Claude bắt đầu phản hồi: đọc file, chạy lệnh, hoặc bất cứ gì tin nhắn yêu cầu. Đây là channel một chiều, nên Claude hành động trong phiên của bạn nhưng không gửi gì ngược lại qua webhook. Để thêm reply, xem Expose một reply tool.

Nếu sự kiện không tới, cách chẩn đoán tùy vào curl trả về gì:

  • curl thành công nhưng không có gì tới Claude: chạy /mcp trong phiên của bạn để kiểm tra trạng thái server. “Failed to connect” thường nghĩa là lỗi dependency hoặc import trong file server; kiểm tra debug log tại ~/.claude/debug/<session-id>.txt để xem stderr trace.
  • curl fail với “connection refused”: cổng chưa được bind, hoặc một process cũ từ lần chạy trước đang giữ nó. lsof -i :<port> cho biết cái gì đang lắng nghe; kill process cũ trước khi khởi động lại phiên.

Server fakechat mở rộng pattern này với web UI, file đính kèm, và một reply tool cho chat hai chiều.

Trong giai đoạn research preview, mọi channel phải nằm trong allowlist đã duyệt để đăng ký được. Development flag bỏ qua allowlist cho các entry cụ thể sau một prompt xác nhận. Ví dụ này cho cả hai kiểu entry:

Terminal window
# Test một plugin bạn đang phát triển
claude --dangerously-load-development-channels plugin:yourplugin@yourmarketplace
# Test một server .mcp.json trơn (chưa có plugin wrapper)
claude --dangerously-load-development-channels server:webhook

Việc bỏ qua áp dụng theo từng entry. Kết hợp flag này với --channels không mở rộng việc bỏ qua tới các entry của --channels. Trong giai đoạn research preview, allowlist đã duyệt do Anthropic quản lý, nên channel của bạn vẫn dùng development flag trong lúc bạn xây dựng và test.

Một channel đặt các tùy chọn này trong constructor Server. Field instructionscapabilities.toolsMCP chuẩn; capabilities.experimental['claude/channel']capabilities.experimental['claude/channel/permission'] là phần bổ sung riêng của channel:

FieldTypeMô tả
capabilities.experimental['claude/channel']objectBắt buộc. Luôn là {}. Sự hiện diện của nó đăng ký listener cho notification.
capabilities.experimental['claude/channel/permission']objectTùy chọn. Luôn là {}. Khai báo rằng channel này có thể nhận yêu cầu relay quyền. Khi khai báo, Claude Code chuyển tiếp prompt duyệt tool tới channel của bạn để bạn duyệt/từ chối từ xa. Xem Relay permission prompt.
capabilities.toolsobjectChỉ dùng cho channel hai chiều. Luôn là {}. Capability tool chuẩn của MCP. Xem Expose một reply tool.
instructionsstringKhuyến nghị. Được thêm vào system prompt của Claude. Cho Claude biết event nào sẽ tới, ý nghĩa các attribute của tag <channel>, có cần reply không, và nếu có thì dùng tool nào, truyền attribute nào ngược lại (như chat_id).

Để tạo channel một chiều, bỏ capabilities.tools. Ví dụ này cho thấy setup hai chiều với channel capability, tools, và instructions:

import { Server } from '@modelcontextprotocol/sdk/server/index.js'
const mcp = new Server(
{ name: 'your-channel', version: '0.0.1' },
{
capabilities: {
experimental: { 'claude/channel': {} }, // đăng ký listener cho channel
tools: {}, // bỏ qua với channel một chiều
},
// được thêm vào system prompt của Claude để nó biết xử lý event của bạn thế nào
instructions: 'Messages arrive as <channel source="your-channel" ...>. Reply with the reply tool.',
},
)

Để đẩy một sự kiện, gọi mcp.notification() với method notifications/claude/channel. Các params ở phần tiếp theo.

Server của bạn emit notifications/claude/channel với hai params:

FieldTypeMô tả
contentstringBody sự kiện. Được gửi làm body của tag <channel>.
metaRecord<string, string>Tùy chọn. Mỗi entry trở thành một attribute trên tag <channel> cho context routing như chat ID, tên người gửi, hoặc mức độ nghiêm trọng cảnh báo. Key phải là identifier: chỉ chữ, số, và underscore. Key chứa dấu gạch ngang hoặc ký tự khác sẽ bị âm thầm loại bỏ.

Server của bạn đẩy sự kiện bằng cách gọi mcp.notification() trên instance Server. Ví dụ này đẩy một cảnh báo CI failure với hai meta key:

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

Sự kiện tới context của Claude được bọc trong tag <channel>. Attribute source được đặt tự động từ tên server bạn đã cấu hình:

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

Claude Code không xác nhận (acknowledge) notification. await trên mcp.notification() resolve khi tin nhắn được ghi vào transport, không phải khi Claude đã xử lý nó. Nếu phiên chưa nạp server của bạn như một channel, hoặc chính sách tổ chức chặn nó, Claude Code âm thầm bỏ sự kiện và không trả lỗi về server của bạn.

Nếu bạn cần xác nhận gửi thành công, theo dõi trạng thái sự kiện trong server của bạn và expose một reply tool để Claude gọi báo cáo trạng thái ngược lại.

Sự kiện được xếp hàng vào phiên và xử lý theo thứ tự. Nếu nhiều notification tới trong lúc Claude đang bận, chúng được gửi cùng nhau ở lượt tiếp theo và Claude xử lý như một nhóm. Để xử lý các luồng sự kiện độc lập song song, chạy các phiên riêng.

Nếu channel của bạn hai chiều, như chat bridge thay vì bộ chuyển tiếp cảnh báo, expose một MCP tool chuẩn để Claude gọi gửi tin nhắn ngược lại. Việc đăng ký tool không có gì đặc thù riêng cho channel. Một reply tool có ba thành phần:

  1. Một entry tools: {} trong capabilities của constructor Server để Claude Code khám phá tool
  2. Tool handler định nghĩa schema của tool và implement logic gửi
  3. Một chuỗi instructions trong constructor Server cho Claude biết khi nào và gọi tool thế nào

Để thêm những phần này vào webhook receiver ở trên:

Bước 1 - Bật tool discovery

Trong constructor Server của webhook.ts, thêm tools: {} vào capabilities để Claude Code biết server của bạn cung cấp tool:

capabilities: {
experimental: { 'claude/channel': {} },
tools: {}, // bật tool discovery
},

Bước 2 - Đăng ký reply tool

Thêm đoạn sau vào webhook.ts. import đặt ở đầu file cùng các import khác; hai handler đặt giữa constructor Servermcp.connect(). Đoạn này đăng ký một tool reply mà Claude có thể gọi với chat_idtext:

// Thêm import này ở đầu webhook.ts
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'
// Claude query cái này lúc khởi động để khám phá tool server của bạn cung cấp
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [{
name: 'reply',
description: 'Send a message back over this channel',
// inputSchema cho Claude biết truyền argument nào
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 gọi cái này khi muốn dùng 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() là hàm outbound của bạn: POST tới chat platform, hoặc cho test
// local, broadcast SSE như ở ví dụ đầy đủ bên dưới.
send(`Reply to ${chat_id}: ${text}`)
return { content: [{ type: 'text', text: 'sent' }] }
}
throw new Error(`unknown tool: ${req.params.name}`)
})

Bước 3 - Cập nhật instructions

Cập nhật chuỗi instructions trong constructor Server để Claude biết route reply ngược qua tool. Ví dụ này bảo Claude truyền chat_id từ tag inbound:

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

Dưới đây là webhook.ts đầy đủ với hỗ trợ hai chiều. Reply outbound stream qua GET /events bằng Server-Sent Events (SSE), nên curl -N localhost:8788/events có thể xem trực tiếp; chat inbound tới trên POST /:

webhook.ts đầy đủ với reply tool
#!/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: ghi tới mọi curl -N listener trên /events --------------------
// Một bridge thật sẽ POST tới chat platform của bạn thay vào đó.
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, // không đóng SSE stream đang idle
async fetch(req) {
const url = new URL(req.url)
// GET /events: SSE stream để curl -N xem reply của Claude live
if (req.method === 'GET' && url.pathname === '/events') {
const stream = new ReadableStream({
start(ctrl) {
ctrl.enqueue(': connected\n\n') // để curl hiện gì đó ngay lập tức
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: chuyển tiếp tới Claude như 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')
},
})

Server fakechat là ví dụ đầy đủ hơn với file đính kèm và chỉnh sửa tin nhắn.

Một channel không gate là một vector prompt injection. Bất cứ ai chạm được endpoint của bạn có thể đưa text ra trước Claude. Một channel lắng nghe chat platform hoặc endpoint công khai cần kiểm tra người gửi thật trước khi emit bất cứ gì.

Kiểm tra người gửi so với allowlist trước khi gọi mcp.notification(). Ví dụ này bỏ mọi tin nhắn từ người gửi không nằm trong tập:

const allowed = new Set(loadAllowlist()) // từ access.json hoặc tương đương
// bên trong message handler của bạn, trước khi emit:
if (!allowed.has(message.from.id)) { // sender, không phải room
return // âm thầm bỏ
}
await mcp.notification({ ... })

Gate theo identity của người gửi, không phải identity của chat hay room: message.from.id trong ví dụ, không phải message.chat.id. Trong group chat, hai cái này khác nhau, và gate theo room sẽ cho phép bất kỳ ai trong group nằm trong allowlist inject tin nhắn vào phiên.

Channel TelegramDiscord gate theo sender allowlist tương tự. Chúng bootstrap danh sách bằng pairing: user DM bot, bot reply mã pairing, user duyệt trong phiên Claude Code của họ, và platform ID của họ được thêm vào. Xem implementation của một trong hai để biết đầy đủ luồng pairing. Channel iMessage tiếp cận khác: nó phát hiện địa chỉ của chính user từ database Messages lúc khởi động và tự động cho qua, với người gửi khác được thêm theo handle.

Khi Claude gọi một tool cần duyệt, dialog terminal local mở ra và phiên đợi. Một channel hai chiều có thể chọn tham gia nhận cùng prompt song song và relay nó tới bạn trên thiết bị khác. Cả hai vẫn hoạt động: bạn có thể trả lời trong terminal hoặc trên điện thoại, và Claude Code áp dụng câu trả lời nào tới trước rồi đóng cái còn lại.

Relay bao trùm các duyệt tool-use như Bash, Write, và Edit. Dialog trust project và consent MCP server không relay; chúng chỉ hiện trong terminal local.

Khi một permission prompt mở ra, vòng lặp relay có bốn bước:

  1. Claude Code sinh một request ID ngắn và notify server của bạn
  2. Server của bạn chuyển tiếp prompt và ID tới chat app của bạn
  3. Người dùng từ xa reply có/không kèm ID đó
  4. Inbound handler của bạn parse reply thành verdict, và Claude Code chỉ áp dụng nếu ID khớp với một request đang mở

Dialog terminal local vẫn mở trong suốt quá trình này. Nếu ai đó tại terminal trả lời trước khi verdict từ xa tới, câu trả lời đó được áp dụng thay thế và request từ xa đang chờ bị bỏ.

(Sequence diagram: Claude Code gửi notification permission_request tới channel server, server định dạng và gửi prompt tới chat app, người dùng reply verdict, và server parse reply đó thành notification permission gửi ngược về Claude Code.)

Notification outbound từ Claude Code là notifications/claude/channel/permission_request. Giống notification channel, transport là MCP chuẩn nhưng method và schema là phần mở rộng của Claude Code. Object params có bốn field string mà server của bạn định dạng thành prompt gửi ra:

FieldMô tả
request_idNăm chữ cái thường lấy từ a-z không có l, để không bao giờ đọc nhầm thành 1 hay I khi gõ trên điện thoại. Đưa nó vào prompt outbound của bạn để có thể được echo lại trong reply. Claude Code chỉ chấp nhận verdict mang ID mà nó đã phát hành. Dialog terminal local không hiện ID này, nên handler outbound của bạn là cách duy nhất để biết nó.
tool_nameTên tool Claude muốn dùng, ví dụ Bash hoặc Write.
descriptionTóm tắt dễ đọc về việc tool call cụ thể này làm gì, không bao giờ là chính câu lệnh. Với một Bash call đây là mô tả của Claude về lệnh; khi model không cho mô tả, field này là hằng số Run shell command và không mang chi tiết lệnh nào. Render input_preview khi bạn có đủ chỗ.
input_previewArgument của tool dưới dạng text hiển thị kiểu JSON, theo từng field top-level. Với Bash đây là câu lệnh; với Write, là đường dẫn file và nội dung. Bỏ nó khỏi prompt nếu bạn chỉ có chỗ cho một dòng thông báo. Server của bạn quyết định hiển thị gì.

Client trên Claude Code v2.1.211 trở lên khử trùng (sanitize) cả hai field trước khi relay: chúng vô hiệu hóa ký tự direction-override và invisible, quote và ký tự trông giống ngoặc nhọn, gộp các chuỗi khoảng trắng thành một khoảng trắng, và relay toàn bộ text tới 3.500 code point, áp dụng theo từng field top-level cho input_preview, vốn cũng giữ nguyên các quote cấu trúc JSON. Một giá trị dài hơn giữ phần đầu và cuối hiển thị quanh một marker đếm được ⋯ N code points elided ⋯, để phần cuối của một lệnh dài vẫn tới được người duyệt. Client cũ hơn relay description thô và cắt input_preview xuống 200 unit UTF-16 kèm dấu chấm lửng ở cuối. Coi cả hai field là không đáng tin cậy trừ khi bạn kiểm soát toàn bộ client.

Verdict server của bạn gửi ngược lại là notifications/claude/channel/permission với hai field: request_id echo lại ID ở trên, và behavior đặt là 'allow' hoặc 'deny'. Allow cho tool call tiếp tục; deny từ chối nó, tương đương trả lời No trong dialog local. Không verdict nào ảnh hưởng tới các lần gọi sau.

Thêm permission relay vào channel hai chiều cần ba thành phần:

  1. Một entry claude/channel/permission: {} dưới experimental capabilities trong constructor Server để Claude Code biết chuyển tiếp prompt
  2. Một notification handler cho notifications/claude/channel/permission_request định dạng prompt và gửi ra qua API platform của bạn
  3. Một kiểm tra trong inbound message handler nhận diện yes <id> hoặc no <id> và emit verdict notifications/claude/channel/permission thay vì chuyển text tới Claude

Chỉ khai báo capability này nếu channel của bạn xác thực người gửi, vì bất cứ ai reply được qua channel của bạn đều có thể duyệt/từ chối tool use trong phiên của bạn.

Để thêm những phần này vào một chat bridge hai chiều như đã lắp ở Expose một reply tool:

Bước 1 - Khai báo permission capability

Trong constructor Server, thêm claude/channel/permission: {} cạnh claude/channel dưới experimental:

capabilities: {
experimental: {
'claude/channel': {},
'claude/channel/permission': {}, // tham gia relay permission
},
tools: {},
},

Bước 2 - Xử lý request tới

Đăng ký một notification handler giữa constructor Servermcp.connect(). Claude Code gọi nó với bốn field request khi một permission dialog mở ra. Handler của bạn định dạng prompt cho platform và kèm chỉ dẫn reply với ID:

import { z } from 'zod'
// setNotificationHandler route theo z.literal trên field method,
// nên schema này vừa là validator vừa là key dispatch
const PermissionRequestSchema = z.object({
method: z.literal('notifications/claude/channel/permission_request'),
params: z.object({
request_id: z.string(), // năm chữ cái thường, đưa nguyên văn vào prompt
tool_name: z.string(), // ví dụ "Bash", "Write"
description: z.string(), // tóm tắt call này. Coi là không đáng tin cậy.
input_preview: z.string(), // argument tool dạng text kiểu JSON. Coi là không đáng tin cậy.
}),
})
mcp.setNotificationHandler(PermissionRequestSchema, async ({ params }) => {
// send() là hàm outbound của bạn: POST tới chat platform, hoặc cho test
// local, broadcast SSE như ở ví dụ đầy đủ bên dưới.
send(
`Claude wants to run ${params.tool_name}: ${params.description}\n` +
// input_preview mang argument thật; render nó khi có
// đủ chỗ: với Bash chỉ riêng description có thể chỉ là
// "Run shell command" không có chi tiết lệnh nào
`${params.input_preview}\n\n` +
// ID trong instruction là thứ inbound handler của bạn parse ở Bước 3
`Reply "yes ${params.request_id}" or "no ${params.request_id}"`,
)
})

Bước 3 - Bắt verdict trong inbound handler

Inbound handler của bạn là vòng lặp hoặc callback nhận tin nhắn từ platform: cùng chỗ bạn gate theo sender và emit notifications/claude/channel để chuyển chat tới Claude. Thêm một kiểm tra trước lệnh forward-chat để nhận diện định dạng verdict và emit permission notification thay vào đó.

Regex khớp định dạng ID Claude Code sinh ra: năm chữ cái, không bao giờ có l. Flag /i chấp nhận autocorrect điện thoại viết hoa reply; hạ chữ thường ID bắt được trước khi gửi ngược lại.

// khớp "y abcde", "yes abcde", "n abcde", "no abcde"
// [a-km-z] là bảng chữ cái ID Claude Code dùng (chữ thường, bỏ 'l')
// /i chấp nhận autocorrect điện thoại; hạ chữ thường capture trước khi gửi
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 theo sender trước
const m = PERMISSION_REPLY_RE.exec(message.text)
if (m) {
// m[1] là từ verdict, m[2] là request ID
// emit verdict notification ngược về Claude Code thay vì chat
await mcp.notification({
method: 'notifications/claude/channel/permission',
params: {
request_id: m[2].toLowerCase(), // chuẩn hóa trong trường hợp autocorrect viết hoa
behavior: m[1].toLowerCase().startsWith('y') ? 'allow' : 'deny',
},
})
return // xử lý như verdict, không forward tiếp như chat
}
// không khớp định dạng verdict: rơi xuống đường chat bình thường
await mcp.notification({
method: 'notifications/claude/channel',
params: { content: message.text, meta: { chat_id: String(message.chat.id) } },
})
}

Claude Code vẫn giữ dialog terminal local mở, nên bạn có thể trả lời ở một trong hai nơi, và câu trả lời tới trước được áp dụng. Một reply từ xa không khớp chính xác định dạng mong đợi fail theo một trong hai cách, và cả hai trường hợp dialog vẫn mở:

  • Định dạng khác: regex của inbound handler không khớp, nên text như approve it hoặc yes không kèm ID rơi xuống như một tin nhắn bình thường gửi tới Claude.
  • Đúng định dạng, sai ID: server của bạn emit một verdict, nhưng Claude Code không tìm thấy request đang mở nào có ID đó và âm thầm bỏ nó.

webhook.ts lắp ráp bên dưới kết hợp cả ba phần mở rộng ở trang này: reply tool, gate sender, và relay permission. Nếu bạn bắt đầu từ đây, bạn cũng cần setup project và entry .mcp.json từ walkthrough ban đầu.

Để test được cả hai chiều từ curl, HTTP listener phục vụ hai path:

  • GET /events: giữ một SSE stream mở và push mỗi tin nhắn outbound thành một dòng data:, để curl -N có thể xem reply và permission prompt của Claude tới live.
  • POST /: phía inbound, cùng handler như trước, giờ có thêm kiểm tra định dạng verdict trước nhánh forward-chat.
webhook.ts đầy đủ với permission relay
#!/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: ghi tới mọi curl -N listener trên /events --------------------
// Một bridge thật sẽ POST tới chat platform của bạn thay vào đó.
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. Với walkthrough local ta tin duy nhất giá trị header
// X-Sender "dev"; một bridge thật sẽ kiểm tra user ID của platform.
const allowed = new Set(['dev'])
const mcp = new Server(
{ name: 'webhook', version: '0.0.1' },
{
capabilities: {
experimental: {
'claude/channel': {},
'claude/channel/permission': {}, // tham gia relay permission
},
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 gọi cái này để gửi tin nhắn ngược lại ---------------
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 (không phải Claude) gọi cái này khi dialog mở
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` +
`${params.input_preview}\n\n` +
`Reply "yes ${params.request_id}" or "no ${params.request_id}"`,
)
})
await mcp.connect(new StdioServerTransport())
// --- HTTP trên :8788: GET /events stream outbound, POST route 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, // không đóng SSE stream đang idle
async fetch(req) {
const url = new URL(req.url)
// GET /events: SSE stream để curl -N xem reply và prompt live
if (req.method === 'GET' && url.pathname === '/events') {
const stream = new ReadableStream({
start(ctrl) {
ctrl.enqueue(': connected\n\n') // để curl hiện gì đó ngay lập tức
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' },
})
}
// mọi thứ khác là inbound: gate theo sender trước
const body = await req.text()
const sender = req.headers.get('X-Sender') ?? ''
if (!allowed.has(sender)) return new Response('forbidden', { status: 403 })
// kiểm tra định dạng verdict trước khi coi là 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')
}
// chat bình thường: chuyển tiếp tới Claude như 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')
},
})

Test đường verdict trong ba terminal. Terminal đầu tiên là phiên Claude Code của bạn, khởi động với development flag để spawn webhook.ts:

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

Ở terminal thứ hai, stream phía outbound để bạn xem reply của Claude và mọi permission prompt khi chúng bắn ra:

Terminal window
curl -N localhost:8788/events

Ở terminal thứ ba, gửi một tin nhắn khiến Claude thử chạy một lệnh:

Terminal window
curl -d "list the files in this directory" -H "X-Sender: dev" localhost:8788

Liệt kê file là read-only, nên Claude chạy nó mà không cần duyệt. Permission dialog mở khi Claude gọi tool reply để gửi câu trả lời ngược lại. Dialog local mở trong terminal Claude Code của bạn, và một lúc sau prompt cho mcp__webhook__reply xuất hiện trong stream /events, gồm cả ID năm chữ. Duyệt nó từ phía từ xa:

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

Dialog local đóng lại, tool reply chạy, và reply của Claude tới stream.

Ba phần đặc thù channel trong file này:

  • Capabilities trong constructor Server: claude/channel đăng ký listener cho notification, claude/channel/permission tham gia relay permission, tools cho Claude khám phá reply tool.
  • Đường outbound: handler tool reply là thứ Claude gọi cho phản hồi hội thoại; handler notification PermissionRequestSchema là thứ Claude Code gọi khi một permission dialog mở. Cả hai đều gọi send() để broadcast qua /events, nhưng được kích hoạt bởi các phần khác nhau của hệ thống.
  • HTTP handler: GET /events giữ một SSE stream mở để curl xem outbound live; POST là inbound, gate theo header X-Sender. Body yes <id> hoặc no <id> đi tới Claude Code như một verdict notification và không bao giờ tới Claude; mọi thứ khác được chuyển tiếp tới Claude như một channel event.

Để channel của bạn cài đặt và chia sẻ được, bọc nó trong một plugin và publish lên một marketplace. User cài nó bằng /plugin install, rồi bật theo từng phiên với --channels plugin:<name>@<marketplace>.

Một channel publish lên marketplace của riêng bạn vẫn cần --dangerously-load-development-channels để chạy, vì nó chưa nằm trong allowlist đã duyệt. Allowlist mặc định là các channel plugin trong claude-plugins-official, do Anthropic quản lý theo quyết định riêng. Form submit trong app thêm plugin vào community marketplace, thứ không nằm trong channel allowlist.

Nếu bạn làm việc với một contact đối tác Anthropic, liên hệ họ để phối hợp một listing trên official marketplace. Trên gói Team và Enterprise, admin có thể thay vào đó thêm plugin của bạn vào danh sách allowedChannelPlugins của tổ chức, thứ thay thế allowlist mặc định của Anthropic.

  • Channels để cài và dùng Telegram, Discord, iMessage, hoặc demo fakechat, và để bật channels cho tổ chức Team hoặc Enterprise
  • Các implementation channel thực tế cho mã server đầy đủ với luồng pairing, reply tool, và file đính kèm
  • MCP cho protocol nền tảng mà channel server implement
  • Plugins để đóng gói channel của bạn sao cho user cài được bằng /plugin install