4 4
5# Channels reference5# Channels reference
6 6
7> 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, and sender gating.7> 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.
8 8
9<Note>9<Note>
10 Channels are in [research preview](/en/channels#research-preview) and require Claude Code v2.1.80 or later. They require claude.ai login. Console and API key authentication is not supported. Team and Enterprise organizations must [explicitly enable them](/en/channels#enterprise-controls).10 Channels are in [research preview](/en/channels#research-preview) and require Claude Code v2.1.80 or later. They require claude.ai login. Console and API key authentication is not supported. Team and Enterprise organizations must [explicitly enable them](/en/channels#enterprise-controls).
12 12
13A channel is an MCP server that pushes events into a Claude Code session so Claude can react to things happening outside the terminal.13A channel is an MCP server that pushes events into a Claude Code session so Claude can react to things happening outside the terminal.
14 14
15You can build a one-way or two-way channel. One-way channels forward alerts, webhooks, or monitoring events for Claude to act on. Two-way channels like chat bridges also [expose a reply tool](#expose-a-reply-tool) so Claude can send messages back.15You can build a one-way or two-way channel. One-way channels forward alerts, webhooks, or monitoring events for Claude to act on. Two-way channels like chat bridges also [expose a reply tool](#expose-a-reply-tool) so Claude can send messages back. A channel with a trusted sender path can also opt in to [relay permission prompts](#relay-permission-prompts) so you can approve or deny tool use remotely.
16 16
17This page covers:17This page covers:
18 18
23* [Notification format](#notification-format): the event payload23* [Notification format](#notification-format): the event payload
24* [Expose a reply tool](#expose-a-reply-tool): let Claude send messages back24* [Expose a reply tool](#expose-a-reply-tool): let Claude send messages back
25* [Gate inbound messages](#gate-inbound-messages): sender checks to prevent prompt injection25* [Gate inbound messages](#gate-inbound-messages): sender checks to prevent prompt injection
26* [Relay permission prompts](#relay-permission-prompts): forward tool approval prompts to remote channels
26 27
27To use an existing channel instead of building one, see [Channels](/en/channels). Telegram, Discord, and fakechat are included in the research preview.28To use an existing channel instead of building one, see [Channels](/en/channels). Telegram, Discord, and fakechat are included in the research preview.
28 29
152 ```153 ```
153 154
154 In your Claude Code terminal, you'll see Claude receive the message and 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).155 In your Claude Code terminal, you'll see Claude receive the message and 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).
156
157 If the event doesn't arrive, the diagnosis depends on what `curl` returned:
158
159 * **`curl` succeeds but nothing reaches Claude**: run `/mcp` in your session to check the server's status. "Failed to connect" usually means a dependency or import error in your server file; check the debug log at `~/.claude/debug/<session-id>.txt` for the stderr trace.
160 * **`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.
155 </Step>161 </Step>
156</Steps>162</Steps>
157 163
177 183
178## Server options184## Server options
179 185
180A channel sets these options in the [`Server`](https://modelcontextprotocol.io/docs/concepts/servers) constructor. The `instructions` and `capabilities.tools` fields are [standard MCP](https://modelcontextprotocol.io/docs/concepts/servers); `capabilities.experimental['claude/channel']` is the channel-specific addition:186A channel sets these options in the [`Server`](https://modelcontextprotocol.io/docs/concepts/servers) constructor. The `instructions` and `capabilities.tools` fields are [standard MCP](https://modelcontextprotocol.io/docs/concepts/servers); `capabilities.experimental['claude/channel']` and `capabilities.experimental['claude/channel/permission']` are the channel-specific additions:
181 187
182| Field | Type | Description |188| Field | Type | Description |
183| :-------------------------------------------- | :------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |189| :------------------------------------------------------- | :------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
184| `capabilities.experimental['claude/channel']` | `object` | Required. Always `{}`. Presence registers the notification listener. |190| `capabilities.experimental['claude/channel']` | `object` | Required. Always `{}`. Presence registers the notification listener. |
191| `capabilities.experimental['claude/channel/permission']` | `object` | Optional. Always `{}`. Declares that this channel can receive permission relay requests. When declared, Claude Code forwards tool approval prompts to your channel so you can approve or deny them remotely. See [Relay permission prompts](#relay-permission-prompts). |
185| `capabilities.tools` | `object` | Two-way only. Always `{}`. Standard MCP tool capability. See [Expose a reply tool](#expose-a-reply-tool). |192| `capabilities.tools` | `object` | Two-way only. Always `{}`. Standard MCP tool capability. See [Expose a reply tool](#expose-a-reply-tool). |
186| `instructions` | `string` | Recommended. Added to Claude's system prompt. Tell Claude what events to expect, what the `<channel>` tag attributes mean, whether to reply, and if so which tool to use and which attribute to pass back (like `chat_id`). |193| `instructions` | `string` | Recommended. Added to Claude's system prompt. Tell Claude what events to expect, what the `<channel>` tag attributes mean, whether to reply, and if so which tool to use and which attribute to pass back (like `chat_id`). |
187 194
188To create a one-way channel, omit `capabilities.tools`. This example shows a two-way setup with all three options set:195To create a one-way channel, omit `capabilities.tools`. This example shows a two-way setup with the channel capability, tools, and instructions set:
189 196
190```ts theme={null}197```ts theme={null}
191import { Server } from '@modelcontextprotocol/sdk/server/index.js'198import { Server } from '@modelcontextprotocol/sdk/server/index.js'
284 mcp.setRequestHandler(CallToolRequestSchema, async req => {291 mcp.setRequestHandler(CallToolRequestSchema, async req => {
285 if (req.params.name === 'reply') {292 if (req.params.name === 'reply') {
286 const { chat_id, text } = req.params.arguments as { chat_id: string; text: string }293 const { chat_id, text } = req.params.arguments as { chat_id: string; text: string }
287 // your platform's send API294 // send() is your outbound: POST to your chat platform, or for local
288 await yourPlatform.send(chat_id, text)295 // testing the SSE broadcast shown in the full example below.
296 send(`Reply to ${chat_id}: ${text}`)
289 return { content: [{ type: 'text', text: 'sent' }] }297 return { content: [{ type: 'text', text: 'sent' }] }
290 }298 }
291 throw new Error(`unknown tool: ${req.params.name}`)299 throw new Error(`unknown tool: ${req.params.name}`)
302 </Step>310 </Step>
303</Steps>311</Steps>
304 312
305Here's the complete `webhook.ts` with two-way support, combining the one-way receiver from the walkthrough with the reply tool additions:313Here'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 /`:
306 314
307```ts title="Full webhook.ts with reply tool" expandable theme={null}315```ts title="Full webhook.ts with reply tool" expandable theme={null}
308#!/usr/bin/env bun316#!/usr/bin/env bun
310import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'318import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
311import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'319import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'
312 320
321// --- Outbound: write to any curl -N listeners on /events --------------------
322// A real bridge would POST to your chat platform instead.
323const listeners = new Set<(chunk: string) => void>()
324function send(text: string) {
325 const chunk = text.split('\n').map(l => `data: ${l}\n`).join('') + '\n'
326 for (const emit of listeners) emit(chunk)
327}
328
313const mcp = new Server(329const mcp = new Server(
314 { name: 'webhook', version: '0.0.1' },330 { name: 'webhook', version: '0.0.1' },
315 {331 {
339mcp.setRequestHandler(CallToolRequestSchema, async req => {355mcp.setRequestHandler(CallToolRequestSchema, async req => {
340 if (req.params.name === 'reply') {356 if (req.params.name === 'reply') {
341 const { chat_id, text } = req.params.arguments as { chat_id: string; text: string }357 const { chat_id, text } = req.params.arguments as { chat_id: string; text: string }
342 // your platform's send API — replace with your real integration358 send(`Reply to ${chat_id}: ${text}`)
343 console.error(`Reply to ${chat_id}: ${text}`)
344 return { content: [{ type: 'text', text: 'sent' }] }359 return { content: [{ type: 'text', text: 'sent' }] }
345 }360 }
346 throw new Error(`unknown tool: ${req.params.name}`)361 throw new Error(`unknown tool: ${req.params.name}`)
352Bun.serve({367Bun.serve({
353 port: 8788,368 port: 8788,
354 hostname: '127.0.0.1',369 hostname: '127.0.0.1',
370 idleTimeout: 0, // don't close idle SSE streams
355 async fetch(req) {371 async fetch(req) {
372 const url = new URL(req.url)
373
374 // GET /events: SSE stream so curl -N can watch Claude's replies live
375 if (req.method === 'GET' && url.pathname === '/events') {
376 const stream = new ReadableStream({
377 start(ctrl) {
378 ctrl.enqueue(': connected\n\n') // so curl shows something immediately
379 const emit = (chunk: string) => ctrl.enqueue(chunk)
380 listeners.add(emit)
381 req.signal.addEventListener('abort', () => listeners.delete(emit))
382 },
383 })
384 return new Response(stream, {
385 headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' },
386 })
387 }
388
389 // POST: forward to Claude as a channel event
356 const body = await req.text()390 const body = await req.text()
357 const chat_id = String(nextId++)391 const chat_id = String(nextId++)
358 await mcp.notification({392 await mcp.notification({
359 method: 'notifications/claude/channel',393 method: 'notifications/claude/channel',
360 params: {394 params: {
361 content: body,395 content: body,
362 meta: { chat_id, path: new URL(req.url).pathname, method: req.method },396 meta: { chat_id, path: url.pathname, method: req.method },
363 },397 },
364 })398 })
365 return new Response('ok')399 return new Response('ok')
389 423
390The [Telegram](https://github.com/anthropics/claude-plugins-official/tree/main/external_plugins/telegram) and [Discord](https://github.com/anthropics/claude-plugins-official/tree/main/external_plugins/discord) channels gate on a sender allowlist the same way. They bootstrap the list by pairing: the user DMs the bot, the bot replies with a pairing code, the user approves it in their Claude Code session, and their platform ID is added. See either implementation for the full pairing flow.424The [Telegram](https://github.com/anthropics/claude-plugins-official/tree/main/external_plugins/telegram) and [Discord](https://github.com/anthropics/claude-plugins-official/tree/main/external_plugins/discord) channels gate on a sender allowlist the same way. They bootstrap the list by pairing: the user DMs the bot, the bot replies with a pairing code, the user approves it in their Claude Code session, and their platform ID is added. See either implementation for the full pairing flow.
391 425
426## Relay permission prompts
427
428<Note>
429 Permission relay requires Claude Code v2.1.81 or later. Earlier versions ignore the `claude/channel/permission` capability.
430</Note>
431
432When Claude calls a tool that needs approval, the local terminal dialog opens and the session waits. A two-way channel can opt in to receive the same prompt in parallel and relay it to you on another device. Both stay live: you can answer in the terminal or on your phone, and Claude Code applies whichever answer arrives first and closes the other.
433
434Relay covers tool-use approvals like `Bash`, `Write`, and `Edit`. Project trust and MCP server consent dialogs don't relay; those only appear in the local terminal.
435
436### How relay works
437
438When a permission prompt opens, the relay loop has four steps:
439
4401. Claude Code generates a short request ID and notifies your server
4412. Your server forwards the prompt and ID to your chat app
4423. The remote user replies with a yes or no and that ID
4434. Your inbound handler parses the reply into a verdict, and Claude Code applies it only if the ID matches an open request
444
445The 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.
446
447<img src="https://mintcdn.com/claude-code/DsZvsJII1OmzIjIs/en/images/channel-permission-relay.svg?fit=max&auto=format&n=DsZvsJII1OmzIjIs&q=85&s=c1d75f6ee34c2757983e2cca899b90d1" 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="en/images/channel-permission-relay.svg" />
448
449### Permission request fields
450
451The outbound notification from Claude Code is `notifications/claude/channel/permission_request`. Like the [channel notification](#notification-format), the transport is standard MCP but the method and schema are Claude Code extensions. The `params` object has four string fields your server formats into the outgoing prompt:
452
453| Field | Description |
454| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
455| `request_id` | Five lowercase letters drawn from `a`-`z` without `l`, so it never reads as a `1` or `I` when typed on a phone. Include it in your outgoing prompt so it can be echoed in the reply. Claude Code only accepts a verdict that carries an ID it issued. The local terminal dialog doesn't display this ID, so your outbound handler is the only way to learn it. |
456| `tool_name` | Name of the tool Claude wants to use, for example `Bash` or `Write`. |
457| `description` | Human-readable summary of what this specific tool call does, the same text the local terminal dialog shows. For a Bash call this is Claude's description of the command, or the command itself if none was given. |
458| `input_preview` | The tool's arguments as a JSON string, truncated to 200 characters. For Bash this is the command; for Write it's the file path and a prefix of the content. Omit it from your prompt if you only have room for a one-line message. Your server decides what to show. |
459
460The verdict your server sends back is `notifications/claude/channel/permission` with two fields: `request_id` echoing the ID above, and `behavior` set to `'allow'` or `'deny'`. Allow lets the tool call proceed; deny rejects it, the same as answering No in the local dialog. Neither verdict affects future calls.
461
462### Add relay to a chat bridge
463
464Adding permission relay to a two-way channel takes three components:
465
4661. A `claude/channel/permission: {}` entry under `experimental` capabilities in your `Server` constructor so Claude Code knows to forward prompts
4672. A notification handler for `notifications/claude/channel/permission_request` that formats the prompt and sends it out through your platform API
4683. A check in your inbound message handler that recognizes `yes <id>` or `no <id>` and emits a `notifications/claude/channel/permission` verdict instead of forwarding the text to Claude
469
470Only declare the capability if your channel [authenticates the sender](#gate-inbound-messages), because anyone who can reply through your channel can approve or deny tool use in your session.
471
472To add these to a two-way chat bridge like the one assembled in [Expose a reply tool](#expose-a-reply-tool):
473
474<Steps>
475 <Step title="Declare the permission capability">
476 In your `Server` constructor, add `claude/channel/permission: {}` alongside `claude/channel` under `experimental`:
477
478 ```ts theme={null}
479 capabilities: {
480 experimental: {
481 'claude/channel': {},
482 'claude/channel/permission': {}, // opt in to permission relay
483 },
484 tools: {},
485 },
486 ```
487 </Step>
488
489 <Step title="Handle the incoming request">
490 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:
491
492 ```ts theme={null}
493 import { z } from 'zod'
494
495 // setNotificationHandler routes by z.literal on the method field,
496 // so this schema is both the validator and the dispatch key
497 const PermissionRequestSchema = z.object({
498 method: z.literal('notifications/claude/channel/permission_request'),
499 params: z.object({
500 request_id: z.string(), // five lowercase letters, include verbatim in your prompt
501 tool_name: z.string(), // e.g. "Bash", "Write"
502 description: z.string(), // human-readable summary of this call
503 input_preview: z.string(), // tool args as JSON, truncated to ~200 chars
504 }),
505 })
506
507 mcp.setNotificationHandler(PermissionRequestSchema, async ({ params }) => {
508 // send() is your outbound: POST to your chat platform, or for local
509 // testing the SSE broadcast shown in the full example below.
510 send(
511 `Claude wants to run ${params.tool_name}: ${params.description}\n\n` +
512 // the ID in the instruction is what your inbound handler parses in Step 3
513 `Reply "yes ${params.request_id}" or "no ${params.request_id}"`,
514 )
515 })
516 ```
517 </Step>
518
519 <Step title="Intercept the verdict in your inbound handler">
520 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.
521
522 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.
523
524 ```ts theme={null}
525 // matches "y abcde", "yes abcde", "n abcde", "no abcde"
526 // [a-km-z] is the ID alphabet Claude Code uses (lowercase, skips 'l')
527 // /i tolerates phone autocorrect; lowercase the capture before sending
528 const PERMISSION_REPLY_RE = /^\s*(y|yes|n|no)\s+([a-km-z]{5})\s*$/i
529
530 async function onInbound(message: PlatformMessage) {
531 if (!allowed.has(message.from.id)) return // gate on sender first
532
533 const m = PERMISSION_REPLY_RE.exec(message.text)
534 if (m) {
535 // m[1] is the verdict word, m[2] is the request ID
536 // emit the verdict notification back to Claude Code instead of chat
537 await mcp.notification({
538 method: 'notifications/claude/channel/permission',
539 params: {
540 request_id: m[2].toLowerCase(), // normalize in case of autocorrect caps
541 behavior: m[1].toLowerCase().startsWith('y') ? 'allow' : 'deny',
542 },
543 })
544 return // handled as verdict, don't also forward as chat
545 }
546
547 // didn't match verdict format: fall through to the normal chat path
548 await mcp.notification({
549 method: 'notifications/claude/channel',
550 params: { content: message.text, meta: { chat_id: String(message.chat.id) } },
551 })
552 }
553 ```
554 </Step>
555</Steps>
556
557Claude Code also keeps the local terminal dialog open, so you can answer in either place, and the first answer to arrive is applied. A remote reply that doesn't exactly match the expected format fails in one of two ways, and in both cases the dialog stays open:
558
559* **Different format**: your inbound handler's regex fails to match, so text like `approve it` or `yes` without an ID falls through as a normal message to Claude.
560* **Right format, wrong ID**: your server emits a verdict, but Claude Code finds no open request with that ID and drops it silently.
561
562### Full example
563
564The assembled `webhook.ts` below combines all three extensions from this page: the reply tool, sender gating, and permission relay. If you're starting here, you'll also need the [project setup and `.mcp.json` entry](#example-build-a-webhook-receiver) from the initial walkthrough.
565
566To make both directions testable from curl, the HTTP listener serves two paths:
567
568* **`GET /events`**: holds an SSE stream open and pushes each outbound message as a `data:` line, so `curl -N` can watch Claude's replies and permission prompts arrive live.
569* **`POST /`**: the inbound side, the same handler as earlier, now with the verdict-format check inserted before the chat-forward branch.
570
571```ts title="Full webhook.ts with permission relay" expandable theme={null}
572#!/usr/bin/env bun
573import { Server } from '@modelcontextprotocol/sdk/server/index.js'
574import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
575import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'
576import { z } from 'zod'
577
578// --- Outbound: write to any curl -N listeners on /events --------------------
579// A real bridge would POST to your chat platform instead.
580const listeners = new Set<(chunk: string) => void>()
581function send(text: string) {
582 const chunk = text.split('\n').map(l => `data: ${l}\n`).join('') + '\n'
583 for (const emit of listeners) emit(chunk)
584}
585
586// Sender allowlist. For the local walkthrough we trust the single X-Sender
587// header value "dev"; a real bridge would check the platform's user ID.
588const allowed = new Set(['dev'])
589
590const mcp = new Server(
591 { name: 'webhook', version: '0.0.1' },
592 {
593 capabilities: {
594 experimental: {
595 'claude/channel': {},
596 'claude/channel/permission': {}, // opt in to permission relay
597 },
598 tools: {},
599 },
600 instructions:
601 'Messages arrive as <channel source="webhook" chat_id="...">. ' +
602 'Reply with the reply tool, passing the chat_id from the tag.',
603 },
604)
605
606// --- reply tool: Claude calls this to send a message back -------------------
607mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
608 tools: [{
609 name: 'reply',
610 description: 'Send a message back over this channel',
611 inputSchema: {
612 type: 'object',
613 properties: {
614 chat_id: { type: 'string', description: 'The conversation to reply in' },
615 text: { type: 'string', description: 'The message to send' },
616 },
617 required: ['chat_id', 'text'],
618 },
619 }],
620}))
621
622mcp.setRequestHandler(CallToolRequestSchema, async req => {
623 if (req.params.name === 'reply') {
624 const { chat_id, text } = req.params.arguments as { chat_id: string; text: string }
625 send(`Reply to ${chat_id}: ${text}`)
626 return { content: [{ type: 'text', text: 'sent' }] }
627 }
628 throw new Error(`unknown tool: ${req.params.name}`)
629})
630
631// --- permission relay: Claude Code (not Claude) calls this when a dialog opens
632const PermissionRequestSchema = z.object({
633 method: z.literal('notifications/claude/channel/permission_request'),
634 params: z.object({
635 request_id: z.string(),
636 tool_name: z.string(),
637 description: z.string(),
638 input_preview: z.string(),
639 }),
640})
641
642mcp.setNotificationHandler(PermissionRequestSchema, async ({ params }) => {
643 send(
644 `Claude wants to run ${params.tool_name}: ${params.description}\n\n` +
645 `Reply "yes ${params.request_id}" or "no ${params.request_id}"`,
646 )
647})
648
649await mcp.connect(new StdioServerTransport())
650
651// --- HTTP on :8788: GET /events streams outbound, POST routes inbound -------
652const PERMISSION_REPLY_RE = /^\s*(y|yes|n|no)\s+([a-km-z]{5})\s*$/i
653let nextId = 1
654
655Bun.serve({
656 port: 8788,
657 hostname: '127.0.0.1',
658 idleTimeout: 0, // don't close idle SSE streams
659 async fetch(req) {
660 const url = new URL(req.url)
661
662 // GET /events: SSE stream so curl -N can watch replies and prompts live
663 if (req.method === 'GET' && url.pathname === '/events') {
664 const stream = new ReadableStream({
665 start(ctrl) {
666 ctrl.enqueue(': connected\n\n') // so curl shows something immediately
667 const emit = (chunk: string) => ctrl.enqueue(chunk)
668 listeners.add(emit)
669 req.signal.addEventListener('abort', () => listeners.delete(emit))
670 },
671 })
672 return new Response(stream, {
673 headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' },
674 })
675 }
676
677 // everything else is inbound: gate on sender first
678 const body = await req.text()
679 const sender = req.headers.get('X-Sender') ?? ''
680 if (!allowed.has(sender)) return new Response('forbidden', { status: 403 })
681
682 // check for verdict format before treating as chat
683 const m = PERMISSION_REPLY_RE.exec(body)
684 if (m) {
685 await mcp.notification({
686 method: 'notifications/claude/channel/permission',
687 params: {
688 request_id: m[2].toLowerCase(),
689 behavior: m[1].toLowerCase().startsWith('y') ? 'allow' : 'deny',
690 },
691 })
692 return new Response('verdict recorded')
693 }
694
695 // normal chat: forward to Claude as a channel event
696 const chat_id = String(nextId++)
697 await mcp.notification({
698 method: 'notifications/claude/channel',
699 params: { content: body, meta: { chat_id, path: url.pathname } },
700 })
701 return new Response('ok')
702 },
703})
704```
705
706Test the verdict path in three terminals. The first is your Claude Code session, started with the [development flag](#test-during-the-research-preview) so it spawns `webhook.ts`:
707
708```bash theme={null}
709claude --dangerously-load-development-channels server:webhook
710```
711
712In the second, stream the outbound side so you can see Claude's replies and any permission prompts as they fire:
713
714```bash theme={null}
715curl -N localhost:8788/events
716```
717
718In the third, send a message that will make Claude try to run a command:
719
720```bash theme={null}
721curl -d "list the files in this directory" -H "X-Sender: dev" localhost:8788
722```
723
724The local permission dialog opens in your Claude Code terminal. A moment later the prompt appears in the `/events` stream, including the five-letter ID. Approve it from the remote side:
725
726```bash theme={null}
727curl -d "yes <id>" -H "X-Sender: dev" localhost:8788
728```
729
730The local dialog closes and the tool runs. Claude's reply comes back through the `reply` tool and lands in the stream too.
731
732The three channel-specific pieces in this file:
733
734* **Capabilities** in the `Server` constructor: `claude/channel` registers the notification listener, `claude/channel/permission` opts in to permission relay, `tools` lets Claude discover the reply tool.
735* **Outbound paths**: the `reply` tool handler is what Claude calls for conversational responses; the `PermissionRequestSchema` notification handler is what Claude Code calls when a permission dialog opens. Both call `send()` to broadcast over `/events`, but they're triggered by different parts of the system.
736* **HTTP handler**: `GET /events` holds an SSE stream open so curl can watch outbound live; `POST` is inbound, gated on the `X-Sender` header. A `yes <id>` or `no <id>` body goes to Claude Code as a verdict notification and never reaches Claude; anything else is forwarded to Claude as a channel event.
737
392## Package as a plugin738## Package as a plugin
393 739
394To make your channel installable and shareable, wrap it in a [plugin](/en/plugins) and publish it to a [marketplace](/en/plugin-marketplaces). Users install it with `/plugin install`, then enable it per session with `--channels plugin:<name>@<marketplace>`.740To make your channel installable and shareable, wrap it in a [plugin](/en/plugins) and publish it to a [marketplace](/en/plugin-marketplaces). Users install it with `/plugin install`, then enable it per session with `--channels plugin:<name>@<marketplace>`.