Follow Discord
Sweep 22 Oct 2026 · 21:04Z Build v2.1.309 528 read Stable v2.1.308 Latest v2.1.308 Next v2.1.309 Feeds RSS JSON llms.txt llms-full.txt Unofficial
One change · claude-code

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
/
lines

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![Architecture diagram showing external systems connecting to your local channel server, which communicates with Claude Code over stdio](https://mintcdn.com/claude-code/9FG0ZKj9uKYiHmbi/images/channel-architecture.svg?fit=max\&auto=format\&n=9FG0ZKj9uKYiHmbi\&q=85\&s=9a037b7da80184ae49015c0256b21a1f)
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![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](https://mintcdn.com/claude-code/9FG0ZKj9uKYiHmbi/images/channel-permission-relay.svg?fit=max\&auto=format\&n=9FG0ZKj9uKYiHmbi\&q=85\&s=97d57f128f0da55f105ab1e3a7e10240)
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 
Feedback