· 6 min read ·

Reactive Sessions: What Claude Code Channels Gets from Event-Driven Architecture

Source: hackernews

The standard model for working with a coding assistant is request-response. You type something, the model replies, you continue. This is true of every chat interface and most API integrations. Claude Code’s channels feature breaks that model by letting external systems push events into a running session.

This shift has a name in the systems programming world: reactive or event-driven architecture. Discord bots run on it. Node.js event emitters run on it. Most UI frameworks run on it. The idea is that the system registers interest in events and gets notified when they occur, rather than polling or waiting for explicit invocation. Channels brings this model to a CLI AI session, and the specific way it implements it is worth understanding in detail.

The Event Listener Analogy

In a Discord bot, you register handlers for event types: client.on('messageCreate', handler). The bot process sits idle until Discord’s gateway delivers an event, at which point the handler fires with the event payload. The bot doesn’t ask “is there a message?”; it waits to be told.

A Claude Code channel works the same way at the architectural level. The channel server is a subprocess connected to Claude Code over stdio. It sits there, connected to whatever external system it integrates, and calls mcp.notification when something happens. Claude Code receives that notification and delivers it to the active session. Claude doesn’t poll; it waits to be told.

The difference from a Discord bot handler is what “handling” means. A bot handler runs deterministic code. A Claude session “handles” the event by reasoning about it: reading the event content, checking what files are already open, deciding whether to act, and calling tools if it does. The event loop is the same. The handler is a language model.

The MCP Notification Primitive

The mechanism Anthropic chose here is a clever reuse of existing infrastructure. MCP already has a notification primitive for servers to push data to clients without being polled. In most MCP implementations this goes mostly unused, since most servers are tool providers that respond to requests. Channels repurpose it as the delivery mechanism for external events.

A channel is an MCP server with one additional capability declaration:

const mcp = new Server(
  { name: 'ci-alerts', version: '0.1.0' },
  {
    capabilities: {
      experimental: { 'claude/channel': {} },
    },
    instructions: 'CI events arrive as <channel source="ci-alerts" ...> tags. When a build fails, investigate the failure using the available tools.',
  },
)

That claude/channel capability is the registration. When Claude Code sees it, it begins listening for notifications/claude/channel messages on that connection. The channel then fires notifications whenever external events warrant:

await mcp.notification({
  method: 'notifications/claude/channel',
  params: {
    content: 'Build #1847 failed on main\nTest suite: 3 failures in auth.test.ts\nhttps://ci.example.com/run/1847',
    meta: { severity: 'high', build_id: '1847', branch: 'main' },
  },
})

Claude Code wraps this in an XML tag and injects it into the conversation:

<channel source="ci-alerts" severity="high" build_id="1847" branch="main">
Build #1847 failed on main
Test suite: 3 failures in auth.test.ts
https://ci.example.com/run/1847
</channel>

That XML lands in the context window as plain text. Claude reads it, sees it’s a build failure, and can act on it using whatever context the session already has. If you’ve been working on auth.test.ts for the last hour, Claude already knows the code. The event arrives in the same space.

A Complete Webhook Channel

For CI integration, the channel server needs to accept HTTP requests from the CI system and translate them into notifications. Here’s a minimal implementation using Bun:

#!/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: 'ci-alerts', version: '0.1.0' },
  {
    capabilities: { experimental: { 'claude/channel': {} } },
    instructions:
      'CI build results arrive as <channel source="ci-alerts" ...> tags. ' +
      'On failure, check the test names mentioned and investigate the relevant files.',
  },
)

await mcp.connect(new StdioServerTransport())

const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET

Bun.serve({
  port: 8788,
  hostname: '127.0.0.1',
  async fetch(req) {
    // Validate the HMAC signature from your CI system
    const sig = req.headers.get('x-hub-signature-256')
    const body = await req.text()
    if (!isValidSignature(body, sig, WEBHOOK_SECRET)) {
      return new Response('Unauthorized', { status: 401 })
    }

    const payload = JSON.parse(body)
    const content = formatBuildEvent(payload)

    await mcp.notification({
      method: 'notifications/claude/channel',
      params: {
        content,
        meta: {
          status: payload.status,
          branch: payload.branch,
          build_id: String(payload.id),
        },
      },
    })

    return new Response('ok')
  },
})

Claude Code runs locally, so the channel server listens on localhost. External systems can’t reach localhost directly. The standard workaround is an ngrok tunnel that exposes a public URL and forwards to the local port. The channel server is the translation layer between the CI system’s HTTP webhook and the MCP notification that Claude Code understands.

The Security Constraint Is Actually the Design

Because event content arrives as text in the context window, it can influence Claude’s behavior. This is not a side effect; it’s the point. But it means the channel server is a gating responsibility.

For webhook channels, HMAC validation handles this. Only your CI system knows the secret, so only genuine events from that system get forwarded. For chat-based channels like the official Telegram or Discord plugins, the gating is more subtle.

In a group chat, the room ID and the sender ID are different things. A channel that allows messages from a room would let any member of that room inject content into your Claude session. The official plugins are explicit about this: they gate on sender ID. You DM the bot, receive a pairing code, approve it inside Claude Code, and lock the policy to allowlist. After that, only your sender ID passes the gate. Other people in the same group chat are filtered before the notification is ever sent.

For custom channels, this filtering logic belongs in your channel server, before calling mcp.notification. Nothing should reach the notification call that you haven’t verified came from a trusted source.

Where the Analogy Breaks Down

Discord bots run continuously. They have a persistent gateway connection, and events queue on Discord’s side if the connection is interrupted. When the bot reconnects, it can request missed events within a window.

Claude Code channels have none of this. Events deliver to an open session. If the session is closed when the CI build fails, the notification goes nowhere. There is no queue, no persistence, no replay.

This is a meaningful limitation for automation use cases. The prescribed workaround is running Claude Code in a persistent terminal session, typically inside tmux or screen. This works, but it means you’re operating a long-lived process that can’t crash without losing events, rather than a proper service with reconnect semantics.

For interactive workflows, this is acceptable. You open a session, work, events flow in, you close it. For always-on monitoring or CI coverage, you need to decide whether the session-lifetime constraint fits your reliability requirements.

The Meta Case

One use case that doesn’t get mentioned enough: using a Claude Code channel while building a Claude Code channel. If you’re developing a Discord bot that integrates with Claude, you can run Claude Code with the Discord channel active, push test events into the session as you develop, and have Claude help debug the integration in real time. The tool you’re building with becomes a consumer of the thing you’re building.

This is more useful than it sounds. The context window already has your bot’s source files. Events from your test Discord server arrive via the channel. Claude can correlate the event payload with the handler code, spot mismatches, and suggest fixes without you copying and pasting anything.

Channels are available in Claude Code v2.1.80 and later, currently requiring a claude.ai login. Team and Enterprise plans have them disabled by default; admins enable them through the admin settings panel. The channels reference covers the full API, including the two-way reply mechanism for chat bridges. The feature is in research preview, which means the operational constraints are real, but the underlying mechanism is solid enough to build on.

Was this interesting?