> Build MCP servers that push webhooks, alerts, and chat messages into a Claude Code session.
From claude-code-expertnpx claudepluginhub markus41/claude --plugin claude-code-expertThis skill uses the workspace's default tool permissions.
Searches, retrieves, and installs Agent Skills from prompts.chat registry using MCP tools like search_skills and get_skill. Activates for finding skills, browsing catalogs, or extending Claude.
Searches prompts.chat for AI prompt templates by keyword or category, retrieves by ID with variable handling, and improves prompts via AI. Use for discovering or enhancing prompts.
Provides process, architecture, review, hiring, and testing guidelines for engineering teams relying on AI code generation.
Build MCP servers that push webhooks, alerts, and chat messages into a Claude Code session. Requires Claude Code v2.1.80+ (permission relay requires v2.1.81+). Research preview — requires claude.ai login; Console/API key auth not supported.
A channel is an MCP server that pushes events into a Claude Code session so Claude can react to things happening outside the terminal. Claude Code spawns it as a subprocess and communicates over stdio.
One-way channels: Forward alerts, webhooks, monitoring events for Claude to act on. Two-way channels: Also expose a reply tool so Claude can send messages back. Permission relay: Trusted two-way channels can forward tool approval prompts to remote devices.
External System → Your Channel Server (local) ←stdio→ Claude Code Session
Telegram, Discord, iMessage, and fakechat are included. Custom channels require --dangerously-load-development-channels flag.
@modelcontextprotocol/sdk package#!/usr/bin/env bun
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
const mcp = new Server(
{ name: 'webhook', version: '0.0.1' },
{
// This key makes it a channel — Claude Code registers a listener
capabilities: { experimental: { 'claude/channel': {} } },
// Added to Claude's system prompt
instructions: 'Events from the webhook channel arrive as <channel source="webhook" ...>. Read them and act, no reply expected.',
},
)
await mcp.connect(new StdioServerTransport())
// HTTP server forwards every POST to Claude
Bun.serve({
port: 8788,
hostname: '127.0.0.1',
async fetch(req) {
const body = await req.text()
await mcp.notification({
method: 'notifications/claude/channel',
params: {
content: body,
meta: { path: new URL(req.url).pathname, method: req.method },
},
})
return new Response('ok')
},
})
{
"mcpServers": {
"webhook": { "command": "bun", "args": ["./webhook.ts"] }
}
}
# Start with development flag
claude --dangerously-load-development-channels server:webhook
# In another terminal, send a test event
curl -X POST localhost:8788 -d "build failed on main: https://ci.example.com/run/1234"
Events arrive as <channel> tags:
<channel source="webhook" path="/" method="POST">build failed on main: https://ci.example.com/run/1234</channel>
| Field | Type | Description |
|---|---|---|
capabilities.experimental['claude/channel'] | object | Required. Always {}. Registers the notification listener. |
capabilities.experimental['claude/channel/permission'] | object | Optional. Enables permission relay for remote tool approval. |
capabilities.tools | object | Two-way only. Always {}. Enables MCP tool discovery. |
instructions | string | Recommended. Added to Claude's system prompt. Describe events, reply behavior, and routing. |
Push events via mcp.notification() with method notifications/claude/channel:
| Field | Type | Description |
|---|---|---|
content | string | Event body — becomes body of <channel> tag |
meta | Record<string, string> | Optional. Each entry becomes a tag attribute (e.g., chat_id, severity). Keys: letters, digits, underscores only. |
await mcp.notification({
method: 'notifications/claude/channel',
params: {
content: 'build failed on main',
meta: { severity: 'high', run_id: '1234' },
},
})
// Arrives as: <channel source="your-channel" severity="high" run_id="1234">build failed on main</channel>
Add a reply tool so Claude can send messages back:
tools: {} to capabilitiesListToolsRequestSchema and CallToolRequestSchema handlersinstructions to tell Claude when/how to replyimport { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [{
name: 'reply',
description: 'Send a message back over this channel',
inputSchema: {
type: 'object',
properties: {
chat_id: { type: 'string', description: 'The conversation to reply in' },
text: { type: 'string', description: 'The message to send' },
},
required: ['chat_id', 'text'],
},
}],
}))
mcp.setRequestHandler(CallToolRequestSchema, async req => {
if (req.params.name === 'reply') {
const { chat_id, text } = req.params.arguments as { chat_id: string; text: string }
send(`Reply to ${chat_id}: ${text}`)
return { content: [{ type: 'text', text: 'sent' }] }
}
throw new Error(`unknown tool: ${req.params.name}`)
})
An ungated channel is a prompt injection vector. Always check sender identity before emitting notifications:
const allowed = new Set(loadAllowlist())
// Gate on sender identity, NOT room/chat identity
if (!allowed.has(message.from.id)) {
return // drop silently
}
await mcp.notification({ ... })
Gate on message.from.id, not message.chat.id — in group chats these differ, and gating on room would let anyone in an allowlisted group inject messages.
Requires Claude Code v2.1.81+. Lets remote users approve/deny tool use from another device.
yes <id> or no <id>Local terminal dialog stays open — first answer (local or remote) wins.
Five lowercase letters from a-z excluding l (avoids confusion with 1/I).
| Field | Description |
|---|---|
request_id | Five-letter ID to echo in verdict |
tool_name | Tool name (e.g., Bash, Write) |
description | Human-readable summary of tool call |
input_preview | Tool args as JSON, truncated to 200 chars |
import { z } from 'zod'
// 1. Declare capability
capabilities: {
experimental: {
'claude/channel': {},
'claude/channel/permission': {}, // opt in
},
tools: {},
},
// 2. Handle incoming permission requests
const PermissionRequestSchema = z.object({
method: z.literal('notifications/claude/channel/permission_request'),
params: z.object({
request_id: z.string(),
tool_name: z.string(),
description: z.string(),
input_preview: z.string(),
}),
})
mcp.setNotificationHandler(PermissionRequestSchema, async ({ params }) => {
send(
`Claude wants to run ${params.tool_name}: ${params.description}\n\n` +
`Reply "yes ${params.request_id}" or "no ${params.request_id}"`,
)
})
// 3. Parse verdict from inbound messages
const PERMISSION_REPLY_RE = /^\s*(y|yes|n|no)\s+([a-km-z]{5})\s*$/i
const m = PERMISSION_REPLY_RE.exec(body)
if (m) {
await mcp.notification({
method: 'notifications/claude/channel/permission',
params: {
request_id: m[2].toLowerCase(),
behavior: m[1].toLowerCase().startsWith('y') ? 'allow' : 'deny',
},
})
return // don't also forward as chat
}
Wrap your channel in a plugin and publish to a marketplace:
/plugin install--channels plugin:<name>@<marketplace>--dangerously-load-development-channelsallowedChannelPlugins list instead| Symptom | Diagnosis |
|---|---|
curl succeeds but event doesn't reach Claude | Run /mcp to check server status. Check ~/.claude/debug/<session-id>.txt for stderr |
curl fails with "connection refused" | Port not bound or stale process. lsof -i :<port> to check, kill stale process |
| "blocked by org policy" | Team/Enterprise admin must enable channels first |
| Permission relay verdict ignored | ID doesn't match open request — check format (5 lowercase letters, no l) |
claude/channel experimental capability<channel source="name" ...>content</channel> tagstools: {} capability and reply tool handlersclaude/channel/permission capability and v2.1.81+