Help us improve
Share bugs, ideas, or general feedback.
From claude-code-expert
Builds MCP channel servers with Bun/Node/Deno to forward webhooks, alerts, and chat messages from external systems like CI or Discord into Claude Code sessions for event-driven coding.
npx claudepluginhub markus41/claude --plugin claude-code-expertHow this skill is triggered — by the user, by Claude, or both
Slash command
/claude-code-expert:channelsThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
> Build MCP servers that push webhooks, alerts, and chat messages into a Claude Code session.
Pushes events from Telegram, Discord, and iMessage into running Claude Code sessions, enabling reactions to CI results, chat messages, monitoring alerts, and webhooks. Requires v2.1.80+.
Claude Channels(텔레그램/디스코드) 자동 셋업. 폰에서 메시지로 Claude Code에 작업 지시 가능. --auto 플래그와 FORGE_OUTPUT=json 프로토콜 공유.
Guides setup of messaging channels (WhatsApp, Telegram, Discord, iMessage, Slack) for external access to Claude Code agents via plugin installs. Shows exact commands, prerequisites, and relaunch steps.
Share bugs, ideas, or general feedback.
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+