Channels reference changedchannels-reference
Nearest release: v2.1.291, published 2 hours after upstream edited the page. Shown because the two are within 24 hours of each other. Nothing here says the release caused the edit.
Upstream edited this page at 6 Oct 2026 01:20 UTC, give or take a minute or two: the time comes from Anthropic’s own sitemap rather than from a commit. This site recorded the change at 20 Oct 2026 18:37 UTC.
Upstream edited
Recorded here
Lines+253added
Lines−263removed
From line
2
where the diff opens
First seen
14 Aug 2026
this site's first read of the page
Recorded edits15to this page, all time
The whole hunk
from line 2, old and new numbered
/
The two sides of this change are more than 400 edits apart, too far apart to line up, so this is the differ's own diff of it and the words inside a line are not marked.
from line 2
22
33> 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.
44
5<Note>
6 Channels are in [research preview](/docs/en/channels#research-preview). Team and Enterprise organizations must [explicitly enable them](/docs/en/channels#enterprise-controls).
7</Note>
5> [!NOTE]
6>
7> Channels are in [research preview](/docs/en/channels#research-preview). Team and Enterprise organizations must [explicitly enable them](/docs/en/channels#enterprise-controls).
88
99A channel is an MCP server that pushes events into a Claude Code session so Claude can react to things happening outside the terminal.
1010
from line 30
3030* **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.
3131* **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.
3232
33<img src="https://mintcdn.com/claude-code/9FG0ZKj9uKYiHmbi/images/channel-architecture.svg?fit=max&auto=format&n=9FG0ZKj9uKYiHmbi&q=85&s=9a037b7da80184ae49015c0256b21a1f" className="dark:hidden" alt="Architecture diagram showing external systems connecting to your local channel server, which communicates with Claude Code over stdio" width="600" height="220" data-path="images/channel-architecture.svg" />
34
35<img src="https://mintcdn.com/claude-code/_xqph1dUOslCOwsj/images/channel-architecture-dark.svg?fit=max&auto=format&n=_xqph1dUOslCOwsj&q=85&s=ae1e494440806a6a5d74a1279e22e162" className="hidden dark:block" alt="Architecture diagram showing external systems connecting to your local channel server, which communicates with Claude Code over stdio" width="600" height="220" data-path="images/channel-architecture-dark.svg" />
33
3634
3735## What you need
3836
from line 52
5452
5553This example uses [Bun](https://bun.sh) as the runtime for its built-in HTTP server and TypeScript support. You can use [Node](https://nodejs.org) or [Deno](https://deno.com) instead; the only requirement is the [MCP SDK](https://www.npmjs.com/package/@modelcontextprotocol/sdk).
5654
57<Steps>
58 <Step title="Create the project">
59 The [permission relay](#relay-permission-prompts) examples import `zod` directly, so it installs alongside the MCP SDK. Create a new directory and install both:
60
61 ```bash theme={null}
62 mkdir webhook-channel && cd webhook-channel
63 bun add @modelcontextprotocol/sdk zod
64 ```
65 </Step>
66
67 <Step title="Write the channel server">
68 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.
69
70 ```ts title="webhook.ts" theme={null}
71 #!/usr/bin/env bun
72 import { Server } from '@modelcontextprotocol/sdk/server/index.js'
73 import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
74
75 // Create the MCP server and declare it as a channel
76 const mcp = new Server(
77 { name: 'webhook', version: '0.0.1' },
78 {
79 // this key is what makes it a channel — Claude Code registers a listener for it
80 capabilities: { experimental: { 'claude/channel': {} } },
81 // Claude Code delivers this to Claude as context when the server connects, so it knows how to handle these events
82 instructions: 'Events from the webhook channel arrive as <channel source="webhook" ...>. They are one-way: read them and act, no reply expected.',
83 },
84 )
85
86 // Connect to Claude Code over stdio (Claude Code spawns this process)
87 await mcp.connect(new StdioServerTransport())
88
89 // Start an HTTP server that forwards every POST to Claude
90 Bun.serve({
91 port: 8788, // any open port works
92 // localhost-only: nothing outside this machine can POST
93 hostname: '127.0.0.1',
94 async fetch(req) {
95 const body = await req.text()
96 await mcp.notification({
97 method: 'notifications/claude/channel',
98 params: {
99 content: body, // becomes the body of the <channel> tag
100 // each key becomes a tag attribute, e.g. <channel path="/" method="POST">
101 meta: { path: new URL(req.url).pathname, method: req.method },
102 },
103 })
104 return new Response('ok')
55**1. Create the project**
56
57The [permission relay](#relay-permission-prompts) examples import `zod` directly, so it installs alongside the MCP SDK. Create a new directory and install both:
58
59```bash theme={null}
60mkdir webhook-channel && cd webhook-channel
61bun add @modelcontextprotocol/sdk zod
62```
63
64**2. Write the channel server**
65
66Create 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.
67
68```ts title="webhook.ts" theme={null}
69#!/usr/bin/env bun
70import { Server } from '@modelcontextprotocol/sdk/server/index.js'
71import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
72
73// Create the MCP server and declare it as a channel
74const mcp = new Server(
75 { name: 'webhook', version: '0.0.1' },
76 {
77 // this key is what makes it a channel — Claude Code registers a listener for it
78 capabilities: { experimental: { 'claude/channel': {} } },
79 // Claude Code delivers this to Claude as context when the server connects, so it knows how to handle these events
80 instructions: 'Events from the webhook channel arrive as <channel source="webhook" ...>. They are one-way: read them and act, no reply expected.',
81 },
82)
83
84// Connect to Claude Code over stdio (Claude Code spawns this process)
85await mcp.connect(new StdioServerTransport())
86
87// Start an HTTP server that forwards every POST to Claude
88Bun.serve({
89 port: 8788, // any open port works
90 // localhost-only: nothing outside this machine can POST
91 hostname: '127.0.0.1',
92 async fetch(req) {
93 const body = await req.text()
94 await mcp.notification({
95 method: 'notifications/claude/channel',
96 params: {
97 content: body, // becomes the body of the <channel> tag
98 // each key becomes a tag attribute, e.g. <channel path="/" method="POST">
99 meta: { path: new URL(req.url).pathname, method: req.method },
105100 },
106101 })
107 ```
108
109 The file configures the server, connects over stdio, and starts an HTTP listener, in that order:
110
111 * **Server configuration**: creates the MCP server with `claude/channel` in its capabilities, which is what tells Claude Code this is a channel. Claude Code delivers the [`instructions`](#server-options) string to Claude as context when the server connects: tell Claude what events to expect, whether to reply, and how to route replies if it should.
112 * **Stdio connection**: connects to Claude Code over stdin/stdout. This is standard for any [MCP server](https://modelcontextprotocol.io/docs/concepts/transports#standard-io).
113 * **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.
114 </Step>
115
116 <Step title="Register your server with Claude Code">
117 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:
118
119 ```json title=".mcp.json" theme={null}
120 {
121 "mcpServers": {
122 "webhook": { "command": "bun", "args": ["./webhook.ts"] }
123 }
124 }
125 ```
126
127 Claude Code reads your MCP config at startup and spawns each server as a subprocess.
128 </Step>
129
130 <Step title="Test it">
131 During the research preview, custom channels aren't on the allowlist, so start Claude Code with the development flag:
132
133 ```bash theme={null}
134 claude --dangerously-load-development-channels server:webhook
135 ```
136
137 Claude Code first shows a full-screen warning dialog listing the development channels you're loading. Select **I am using this for local development** to continue, or **Exit** to quit.
138
139 The first time you start a session in this project, Claude Code also asks for consent before using the new server from `.mcp.json`. The dialog reports "New MCP server found in this project: webhook". Select **Use this MCP server** to continue.
140
141 After you accept, Claude Code 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.
142
143 A dim notice below the startup banner confirms the channel is registered: `Channels (experimental) messages from server:webhook inject directly in this session · restart without --dangerously-load-development-channels to stop`.
144
145 If you see "blocked by org policy," your organization admin needs to [enable channels](/docs/en/channels#enterprise-controls) first.
146
147 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):
148
149 ```bash theme={null}
150 curl -X POST localhost:8788 -d "build failed on main: https://ci.example.com/run/1234"
151 ```
152
153 The payload arrives in Claude's context as a `<channel>` tag:
154
155 ```text theme={null}
156 <channel source="webhook" path="/" method="POST">
157 build failed on main: https://ci.example.com/run/1234
158 </channel>
159 ```
160
161 Your terminal renders the event as a one-line summary, `← webhook: build failed on main: https://ci.example.com/run/1234`, rather than the raw tag. You'll then see Claude 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](#expose-a-reply-tool).
162
163 If the event doesn't arrive, the diagnosis depends on what `curl` returned:
164
165 * **`curl` succeeds but nothing reaches Claude**: run `/mcp` in your session to check the server's status. A `failed` status usually means a dependency or import error in your server file. To see the stderr trace, restart with `claude --debug --dangerously-load-development-channels server:webhook` and check the debug log at `~/.claude/debug/<session-id>.txt`.
166 * **`curl` fails with "connection refused"**: the port is either not bound yet or a stale process from an earlier run is holding it. `lsof -i :<port>` shows what's listening; `kill` the stale process before restarting your session.
167 </Step>
168</Steps>
102 return new Response('ok')
103 },
104})
105```
106
107The file configures the server, connects over stdio, and starts an HTTP listener, in that order:
108
109* **Server configuration**: creates the MCP server with `claude/channel` in its capabilities, which is what tells Claude Code this is a channel. Claude Code delivers the [`instructions`](#server-options) string to Claude as context when the server connects: tell Claude what events to expect, whether to reply, and how to route replies if it should.
110* **Stdio connection**: connects to Claude Code over stdin/stdout. This is standard for any [MCP server](https://modelcontextprotocol.io/docs/concepts/transports#standard-io).
111* **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.
112
113**3. Register your server with Claude Code**
114
115Add 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:
116
117```json title=".mcp.json" theme={null}
118{
119 "mcpServers": {
120 "webhook": { "command": "bun", "args": ["./webhook.ts"] }
121 }
122}
123```
124
125Claude Code reads your MCP config at startup and spawns each server as a subprocess.
126
127**4. Test it**
128
129During the research preview, custom channels aren't on the allowlist, so start Claude Code with the development flag:
130
131```bash theme={null}
132claude --dangerously-load-development-channels server:webhook
133```
134
135Claude Code first shows a full-screen warning dialog listing the development channels you're loading. Select **I am using this for local development** to continue, or **Exit** to quit.
136
137The first time you start a session in this project, Claude Code also asks for consent before using the new server from `.mcp.json`. The dialog reports "New MCP server found in this project: webhook". Select **Use this MCP server** to continue.
138
139After you accept, Claude Code 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.
140
141A dim notice below the startup banner confirms the channel is registered: `Channels (experimental) messages from server:webhook inject directly in this session · restart without --dangerously-load-development-channels to stop`.
142
143If you see "blocked by org policy," your organization admin needs to [enable channels](/docs/en/channels#enterprise-controls) first.
144
145In 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):
146
147```bash theme={null}
148curl -X POST localhost:8788 -d "build failed on main: https://ci.example.com/run/1234"
149```
150
151The payload arrives in Claude's context as a `<channel>` tag:
152
153```text theme={null}
154<channel source="webhook" path="/" method="POST">
155build failed on main: https://ci.example.com/run/1234
156</channel>
157```
158
159Your terminal renders the event as a one-line summary, `← webhook: build failed on main: https://ci.example.com/run/1234`, rather than the raw tag. You'll then see Claude 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](#expose-a-reply-tool).
160
161If the event doesn't arrive, the diagnosis depends on what `curl` returned:
162
163* **`curl` succeeds but nothing reaches Claude**: run `/mcp` in your session to check the server's status. A `failed` status usually means a dependency or import error in your server file. To see the stderr trace, restart with `claude --debug --dangerously-load-development-channels server:webhook` and check the debug log at `~/.claude/debug/<session-id>.txt`.
164* **`curl` fails with "connection refused"**: the port is either not bound yet or a stale process from an earlier run is holding it. `lsof -i :<port>` shows what's listening; `kill` the stale process before restarting your session.
169165
170166The [fakechat server](https://github.com/anthropics/claude-plugins-official/tree/main/external_plugins/fakechat) extends this pattern with a web UI, file attachments, and a reply tool for two-way chat.
171167
from line 181
185181
186182The bypass is per-entry. Combining this flag with `--channels` doesn't extend the bypass to the `--channels` entries. During the research preview, your channel isn't on the approved allowlist, so it stays on the development flag while you build and test.
187183
188<Note>
189 This flag skips the allowlist only. The `channelsEnabled` organization policy still applies. Don't use it to run channels from untrusted sources.
190</Note>
184> [!NOTE]
185>
186> This flag skips the allowlist only. The `channelsEnabled` organization policy still applies. Don't use it to run channels from untrusted sources.
191187
192188## Server options
193189
from line 259
263259
264260To add these to the [webhook receiver above](#example-build-a-webhook-receiver):
265261
266<Steps>
267 <Step title="Enable tool discovery">
268 In your `Server` constructor in `webhook.ts`, add `tools: {}` to the capabilities so Claude Code knows your server offers tools:
269
270 ```ts theme={null}
271 capabilities: {
272 experimental: { 'claude/channel': {} },
273 tools: {}, // enables tool discovery
262**1. Enable tool discovery**
263
264In your `Server` constructor in `webhook.ts`, add `tools: {}` to the capabilities so Claude Code knows your server offers tools:
265
266```ts theme={null}
267capabilities: {
268 experimental: { 'claude/channel': {} },
269 tools: {}, // enables tool discovery
270},
271```
272
273**2. Register the reply tool**
274
275Add 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`:
276
277```ts theme={null}
278// Add this import at the top of webhook.ts
279import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'
280
281// Claude queries this at startup to discover what tools your server offers
282mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
283 tools: [{
284 name: 'reply',
285 description: 'Send a message back over this channel',
286 // inputSchema tells Claude what arguments to pass
287 inputSchema: {
288 type: 'object',
289 properties: {
290 chat_id: { type: 'string', description: 'The conversation to reply in' },
291 text: { type: 'string', description: 'The message to send' },
292 },
293 required: ['chat_id', 'text'],
274294 },
275 ```
276 </Step>
277
278 <Step title="Register the reply tool">
279 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`:
280
281 ```ts theme={null}
282 // Add this import at the top of webhook.ts
283 import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'
284
285 // Claude queries this at startup to discover what tools your server offers
286 mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
287 tools: [{
288 name: 'reply',
289 description: 'Send a message back over this channel',
290 // inputSchema tells Claude what arguments to pass
291 inputSchema: {
292 type: 'object',
293 properties: {
294 chat_id: { type: 'string', description: 'The conversation to reply in' },
295 text: { type: 'string', description: 'The message to send' },
296 },
297 required: ['chat_id', 'text'],
298 },
299 }],
300 }))
301
302 // Claude calls this when it wants to invoke a tool
303 mcp.setRequestHandler(CallToolRequestSchema, async req => {
304 if (req.params.name === 'reply') {
305 const { chat_id, text } = req.params.arguments as { chat_id: string; text: string }
306 // send() is your outbound: POST to your chat platform, or for local
307 // testing the SSE broadcast shown in the full example below.
308 send(`Reply to ${chat_id}: ${text}`)
309 return { content: [{ type: 'text', text: 'sent' }] }
310 }
311 throw new Error(`unknown tool: ${req.params.name}`)
312 })
313 ```
314 </Step>
315
316 <Step title="Update the instructions">
317 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:
318
319 ```ts theme={null}
320 instructions: 'Messages arrive as <channel source="webhook" chat_id="...">. Reply with the reply tool, passing the chat_id from the tag.'
321 ```
322 </Step>
323</Steps>
295 }],
296}))
297
298// Claude calls this when it wants to invoke a tool
299mcp.setRequestHandler(CallToolRequestSchema, async req => {
300 if (req.params.name === 'reply') {
301 const { chat_id, text } = req.params.arguments as { chat_id: string; text: string }
302 // send() is your outbound: POST to your chat platform, or for local
303 // testing the SSE broadcast shown in the full example below.
304 send(`Reply to ${chat_id}: ${text}`)
305 return { content: [{ type: 'text', text: 'sent' }] }
306 }
307 throw new Error(`unknown tool: ${req.params.name}`)
308})
309```
310
311**3. Update the instructions**
312
313Update 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:
314
315```ts theme={null}
316instructions: 'Messages arrive as <channel source="webhook" chat_id="...">. Reply with the reply tool, passing the chat_id from the tag.'
317```
324318
325319Here's the complete `webhook.ts` with two-way support. Outbound replies stream over `GET /events` using [Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) (SSE), so `curl -N localhost:8788/events` can watch them live; inbound chat arrives on `POST /`:
326320
from line 448
454448
455449The local terminal dialog stays open through all of this. If someone at the terminal answers before the remote verdict arrives, that answer is applied instead and the pending remote request is dropped.
456450
457<img src="https://mintcdn.com/claude-code/9FG0ZKj9uKYiHmbi/images/channel-permission-relay.svg?fit=max&auto=format&n=9FG0ZKj9uKYiHmbi&q=85&s=97d57f128f0da55f105ab1e3a7e10240" className="dark:hidden" alt="Sequence diagram: Claude Code sends a permission_request notification to the channel server, the server formats and sends the prompt to the chat app, the human replies with a verdict, and the server parses that reply into a permission notification back to Claude Code" width="600" height="230" data-path="images/channel-permission-relay.svg" />
458
459<img src="https://mintcdn.com/claude-code/_xqph1dUOslCOwsj/images/channel-permission-relay-dark.svg?fit=max&auto=format&n=_xqph1dUOslCOwsj&q=85&s=368c8d9119a9a9cff5d826d806724842" className="hidden dark:block" alt="Sequence diagram: Claude Code sends a permission_request notification to the channel server, the server formats and sends the prompt to the chat app, the human replies with a verdict, and the server parses that reply into a permission notification back to Claude Code" width="600" height="230" data-path="images/channel-permission-relay-dark.svg" />
451
460452
461453### Permission request fields
462454
from line 493
501493
502494To add these to a two-way chat bridge like the one assembled in [Expose a reply tool](#expose-a-reply-tool):
503495
504<Steps>
505 <Step title="Declare the permission capability">
506 In your `Server` constructor, add `claude/channel/permission: {}` alongside `claude/channel` under `experimental`:
507
508 ```ts theme={null}
509 capabilities: {
510 experimental: {
511 'claude/channel': {},
512 'claude/channel/permission': {}, // opt in to permission relay
496**1. Declare the permission capability**
497
498In your `Server` constructor, add `claude/channel/permission: {}` alongside `claude/channel` under `experimental`:
499
500```ts theme={null}
501capabilities: {
502 experimental: {
503 'claude/channel': {},
504 'claude/channel/permission': {}, // opt in to permission relay
505 },
506 tools: {},
507},
508```
509
510**2. Handle the incoming request**
511
512Register a notification handler between your `Server` constructor and `mcp.connect()`. Claude Code calls it with the [four request fields](#permission-request-fields) when a permission dialog opens. Your handler formats the prompt for your platform and includes instructions for replying with the ID:
513
514```ts theme={null}
515import { z } from 'zod'
516
517// setNotificationHandler routes by z.literal on the method field,
518// so this schema is both the validator and the dispatch key
519const PermissionRequestSchema = z.object({
520 method: z.literal('notifications/claude/channel/permission_request'),
521 params: z.object({
522 request_id: z.string(), // five lowercase letters, include verbatim in your prompt
523 tool_name: z.string(), // e.g. "Bash", "Write"
524 description: z.string(), // summary of this call. Treat as untrusted.
525 input_preview: z.string(), // tool args as JSON-shaped text. Treat as untrusted.
526 }),
527})
528
529mcp.setNotificationHandler(PermissionRequestSchema, async ({ params }) => {
530 // send() is your outbound: POST to your chat platform, or for local
531 // testing the SSE broadcast shown in the full example below.
532 send(
533 `Claude wants to run ${params.tool_name}: ${params.description}\n` +
534 // input_preview carries the actual arguments; render it when you
535 // have room: for Bash the description alone may be just
536 // "Run shell command" with zero command detail
537 `${params.input_preview}\n\n` +
538 // the ID in the instruction is what your inbound handler parses in Step 3
539 `Reply "yes ${params.request_id}" or "no ${params.request_id}"`,
540 )
541})
542```
543
544**3. Intercept the verdict in your inbound handler**
545
546Your inbound handler is the loop or callback that receives messages from your platform: the same place you [gate on sender](#gate-inbound-messages) and emit `notifications/claude/channel` to forward chat to Claude. Add a check before the chat-forwarding call that recognizes the verdict format and emits the permission notification instead.
547
548The regex matches the ID format Claude Code generates: five letters, never `l`. The `/i` flag tolerates phone autocorrect capitalizing the reply; lowercase the captured ID before sending it back.
549
550```ts theme={null}
551// matches "y abcde", "yes abcde", "n abcde", "no abcde"
552// [a-km-z] is the ID alphabet Claude Code uses (lowercase, skips 'l')
553// /i tolerates phone autocorrect; lowercase the capture before sending
554const PERMISSION_REPLY_RE = /^\s*(y|yes|n|no)\s+([a-km-z]{5})\s*$/i
555
556async function onInbound(message: PlatformMessage) {
557 if (!allowed.has(message.from.id)) return // gate on sender first
558
559 const m = PERMISSION_REPLY_RE.exec(message.text)
560 if (m) {
561 // m[1] is the verdict word, m[2] is the request ID
562 // emit the verdict notification back to Claude Code instead of chat
563 await mcp.notification({
564 method: 'notifications/claude/channel/permission',
565 params: {
566 request_id: m[2].toLowerCase(), // normalize in case of autocorrect caps
567 behavior: m[1].toLowerCase().startsWith('y') ? 'allow' : 'deny',
513568 },
514 tools: {},
515 },
516 ```
517 </Step>
518
519 <Step title="Handle the incoming request">
520 Register a notification handler between your `Server` constructor and `mcp.connect()`. Claude Code calls it with the [four request fields](#permission-request-fields) when a permission dialog opens. Your handler formats the prompt for your platform and includes instructions for replying with the ID:
521
522 ```ts theme={null}
523 import { z } from 'zod'
524
525 // setNotificationHandler routes by z.literal on the method field,
526 // so this schema is both the validator and the dispatch key
527 const PermissionRequestSchema = z.object({
528 method: z.literal('notifications/claude/channel/permission_request'),
529 params: z.object({
530 request_id: z.string(), // five lowercase letters, include verbatim in your prompt
531 tool_name: z.string(), // e.g. "Bash", "Write"
532 description: z.string(), // summary of this call. Treat as untrusted.
533 input_preview: z.string(), // tool args as JSON-shaped text. Treat as untrusted.
534 }),
535569 })
536
537 mcp.setNotificationHandler(PermissionRequestSchema, async ({ params }) => {
538 // send() is your outbound: POST to your chat platform, or for local
539 // testing the SSE broadcast shown in the full example below.
540 send(
541 `Claude wants to run ${params.tool_name}: ${params.description}\n` +
542 // input_preview carries the actual arguments; render it when you
543 // have room: for Bash the description alone may be just
544 // "Run shell command" with zero command detail
545 `${params.input_preview}\n\n` +
546 // the ID in the instruction is what your inbound handler parses in Step 3
547 `Reply "yes ${params.request_id}" or "no ${params.request_id}"`,
548 )
549 })
550 ```
551 </Step>
552
553 <Step title="Intercept the verdict in your inbound handler">
554 Your inbound handler is the loop or callback that receives messages from your platform: the same place you [gate on sender](#gate-inbound-messages) and emit `notifications/claude/channel` to forward chat to Claude. Add a check before the chat-forwarding call that recognizes the verdict format and emits the permission notification instead.
555
556 The regex matches the ID format Claude Code generates: five letters, never `l`. The `/i` flag tolerates phone autocorrect capitalizing the reply; lowercase the captured ID before sending it back.
557
558 ```ts theme={null}
559 // matches "y abcde", "yes abcde", "n abcde", "no abcde"
560 // [a-km-z] is the ID alphabet Claude Code uses (lowercase, skips 'l')
561 // /i tolerates phone autocorrect; lowercase the capture before sending
562 const PERMISSION_REPLY_RE = /^\s*(y|yes|n|no)\s+([a-km-z]{5})\s*$/i
563
564 async function onInbound(message: PlatformMessage) {
565 if (!allowed.has(message.from.id)) return // gate on sender first
566
567 const m = PERMISSION_REPLY_RE.exec(message.text)
568 if (m) {
569 // m[1] is the verdict word, m[2] is the request ID
570 // emit the verdict notification back to Claude Code instead of chat
571 await mcp.notification({
572 method: 'notifications/claude/channel/permission',
573 params: {
574 request_id: m[2].toLowerCase(), // normalize in case of autocorrect caps
575 behavior: m[1].toLowerCase().startsWith('y') ? 'allow' : 'deny',
576 },
577 })
578 return // handled as verdict, don't also forward as chat
579 }
580
581 // didn't match verdict format: fall through to the normal chat path
582 await mcp.notification({
583 method: 'notifications/claude/channel',
584 params: { content: message.text, meta: { chat_id: String(message.chat.id) } },
585 })
586 }
587 ```
588 </Step>
589</Steps>
570 return // handled as verdict, don't also forward as chat
571 }
572
573 // didn't match verdict format: fall through to the normal chat path
574 await mcp.notification({
575 method: 'notifications/claude/channel',
576 params: { content: message.text, meta: { chat_id: String(message.chat.id) } },
577 })
578}
579```
590580
591581A remote reply that doesn't exactly match the expected format fails in one of two ways, and in both cases the local terminal dialog stays open:
592582
593583
No line in this hunk matches that.