Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.agentfront.dev/llms.txt

Use this file to discover all available pages before exploring further.

Channels are push-based notification streams that deliver real-time events into Claude Code sessions. They enable your MCP server to forward webhooks, application errors, agent completions, job results, and chat messages directly into Claude’s context — with optional two-way reply support.
This feature implements the Claude Code Channels extension — an experimental MCP protocol extension using notifications/claude/channel.

Why Channels?

Channels fill a gap that tools, resources, and prompts cannot address — server-initiated push notifications:
AspectToolResourceChannel
PurposeExecute actionsProvide dataPush real-time events
DirectionClient triggersClient pullsServer pushes
Side effectsYesNoNo (notification only)
Two-wayRequest/responseRead-onlyOptional reply tool
Use caseActions, calculationsContext loadingAlerts, chat bridges, monitoring
Channels are ideal for:
  • CI/CD alerts — deploy status, build failures, PR notifications
  • Chat bridges — WhatsApp, Telegram, Slack, Discord messaging with Claude
  • Error monitoring — forward application errors for debugging assistance
  • Agent notifications — notify when AI agents complete background tasks
  • Job completion — alert when background jobs or workflows finish

Creating Channels

Class Style

Use class decorators for channels that need complex message transformation or two-way communication:
import { Channel, ChannelContext } from '@frontmcp/sdk';
import type { ChannelNotification } from '@frontmcp/sdk';

@Channel({
  name: 'deploy-alerts',
  description: 'CI/CD deployment notifications',
  source: { type: 'webhook', path: '/hooks/deploy' },
  meta: { team: 'platform' },
})
class DeployAlertChannel extends ChannelContext {
  async onEvent(payload: unknown): Promise<ChannelNotification> {
    const { body } = payload as { body: { status: string; version: string } };
    return {
      content: `Deploy ${body.status}: ${body.version}`,
      meta: { status: body.status },
    };
  }
}

Function Style

Use the channel() builder for simple transform-only channels:
import { channel } from '@frontmcp/sdk';

const ErrorChannel = channel({
  name: 'error-alerts',
  source: { type: 'app-event', event: 'app:error' },
})((payload) => {
  const error = payload as { message: string; level: string };
  return {
    content: `[${error.level.toUpperCase()}] ${error.message}`,
    meta: { severity: error.level },
  };
});

Channel Sources

Every channel has a source that determines how events flow into it:
SourceTriggerConfig
webhookHTTP POST to a path{ type: 'webhook', path: '/hooks/deploy' }
app-eventIn-process event bus{ type: 'app-event', event: 'error' }
agent-completionAgent finishes{ type: 'agent-completion', agentIds?: ['reviewer'] }
job-completionJob completes{ type: 'job-completion', jobNames?: ['daily-report'] }
servicePersistent connection{ type: 'service', service: 'whatsapp-business' }
manualProgrammatic push{ type: 'manual' }

Webhook Source

External services send HTTP POST requests to the configured path:
@Channel({
  name: 'github-events',
  source: { type: 'webhook', path: '/hooks/github' },
})
class GitHubChannel extends ChannelContext {
  async onEvent(payload: unknown): Promise<ChannelNotification> {
    const { body, headers } = payload as {
      body: Record<string, unknown>;
      headers: Record<string, string | string[] | undefined>;
    };
    const eventType = headers['x-github-event'] as string;
    return { content: `GitHub: ${eventType} event`, meta: { event: eventType } };
  }
}

App Event Source

Your application emits events to the ChannelEventBus:
// Channel declaration
@Channel({
  name: 'errors',
  source: { type: 'app-event', event: 'app:error' },
})
class ErrorChannel extends ChannelContext { /* ... */ }

// Emit from anywhere with scope access
scope.channelEventBus.emit('app:error', {
  message: 'Connection refused',
  level: 'critical',
});

Agent & Job Completion

Auto-subscribe to system events with optional filtering:
// Notify when specific agents finish
@Channel({
  name: 'agent-done',
  source: { type: 'agent-completion', agentIds: ['code-reviewer'] },
})
class AgentDoneChannel extends ChannelContext { /* ... */ }

// Notify when specific jobs complete
@Channel({
  name: 'job-done',
  source: { type: 'job-completion', jobNames: ['daily-report'] },
})
class JobDoneChannel extends ChannelContext { /* ... */ }

Service Connector

Service connectors maintain persistent connections to external messaging services. Claude sends messages via channel-contributed tools, and incoming responses arrive as channel notifications. This is the most powerful channel pattern — it turns Claude Code into a full messaging client.
import { Channel, ChannelContext, Tool, ToolContext } from '@frontmcp/sdk';
import type { ChannelNotification } from '@frontmcp/sdk';
import { z } from '@frontmcp/sdk';

// Outbound tool — Claude calls this to send messages
@Tool({
  name: 'send-whatsapp',
  description: 'Send a WhatsApp message. Replies will arrive as channel notifications.',
  inputSchema: {
    to: z.string().describe('Recipient phone number'),
    text: z.string().describe('Message text'),
  },
})
class SendWhatsAppTool extends ToolContext {
  async execute(input: { to: string; text: string }) {
    const client = this.get(WhatsAppClientToken);
    await client.sendMessage(input.to, input.text);
    return { sent: true, to: input.to };
  }
}

// Channel — maintains connection, receives responses
@Channel({
  name: 'whatsapp',
  description: 'WhatsApp messaging. Send via send-whatsapp tool, replies arrive here.',
  source: { type: 'service', service: 'whatsapp-business' },
  tools: [SendWhatsAppTool],  // Auto-registered in tool registry
  twoWay: true,
})
class WhatsAppChannel extends ChannelContext {
  private client: WhatsAppClient;

  async onConnect(): Promise<void> {
    this.client = new WhatsAppClient(process.env['WHATSAPP_TOKEN']);
    // Incoming messages from WhatsApp → push into notification pipeline
    this.client.on('message', (msg) => {
      this.pushIncoming({
        from: msg.from,
        text: msg.body,
        chatId: msg.chatId,
      });
    });
    await this.client.connect();
  }

  async onDisconnect(): Promise<void> {
    await this.client.disconnect();
  }

  async onEvent(payload: unknown): Promise<ChannelNotification> {
    const msg = payload as { from: string; text: string; chatId: string };
    return {
      content: `${msg.from}: ${msg.text}`,
      meta: { chat_id: msg.chatId, sender: msg.from },
    };
  }
}
The conversation flow:
  1. Claude calls send-whatsapp({ to: "+1234567890", text: "Hi Alice!" })
  2. Message is delivered to Alice’s WhatsApp
  3. Alice replies “Got it, will review the PR”
  4. onConnect()’s listener fires → pushIncoming()onEvent() transforms it
  5. Claude sees: <channel source="whatsapp" sender="Alice">Alice: Got it, will review the PR</channel>
  6. Claude can continue the conversation by calling send-whatsapp again
Service connector channels call onConnect() during scope initialization and onDisconnect() during teardown. The connection persists for the lifetime of the server.

File Watcher

Watch file system paths and notify Claude when files change. Uses onConnect() to start watching.
@Channel({
  name: 'log-watcher',
  description: 'Watches log files for new errors',
  source: {
    type: 'file-watcher',
    paths: ['./logs/app.log', './logs/error.log'],
    events: ['change', 'create'],
  },
})
class LogWatcher extends ChannelContext {
  private watchers: any[] = [];

  async onConnect(): Promise<void> {
    const { watch } = require('node:fs');
    for (const path of (this.metadata.source as any).paths) {
      this.watchers.push(
        watch(path, () => this.pushIncoming({ file: path })),
      );
    }
  }

  async onDisconnect(): Promise<void> {
    this.watchers.forEach((w) => w.close());
  }

  async onEvent(payload: unknown): Promise<ChannelNotification> {
    const { file } = payload as { file: string };
    return { content: `File changed: ${file}`, meta: { file } };
  }
}

Manual Push

Push notifications programmatically from any code with scope access:
scope.channelNotifications.send('status', 'Server maintenance in 5 minutes');

Replay Buffer

By default, events are lost when no Claude Code sessions are connected. Enable replay to buffer events:
@Channel({
  name: 'ci-alerts',
  source: { type: 'webhook', path: '/hooks/ci' },
  replay: {
    enabled: true,
    maxEvents: 100,  // Ring buffer — oldest events evicted when full
  },
})
class CIAlertChannel extends ChannelContext {
  async onEvent(payload: unknown): Promise<ChannelNotification> {
    // ...
  }
}
How it works:
  1. Events arrive and are processed normally via onEvent()
  2. Each notification is also stored in an in-memory ring buffer
  3. When a new session connects, replayBufferedEvents(sessionId) sends all buffered events
  4. Replayed events include replayed: "true" in their meta
For events that must survive server restarts, combine replay with a persistent store (Redis, SQLite) in onConnect(). See the replay buffer skill example for the pattern.

Two-Way Communication

Set twoWay: true to enable Claude Code to reply back through the channel. This auto-registers a channel-reply tool.
@Channel({
  name: 'whatsapp',
  description: 'WhatsApp chat bridge',
  source: { type: 'webhook', path: '/hooks/whatsapp' },
  twoWay: true,
})
class WhatsAppChannel extends ChannelContext {
  async onEvent(payload: unknown): Promise<ChannelNotification> {
    const { body } = payload as { body: { sender: string; text: string; chatId: string } };
    return {
      content: `${body.sender}: ${body.text}`,
      meta: { chat_id: body.chatId, sender: body.sender },
    };
  }

  async onReply(reply: string, meta?: Record<string, string>): Promise<void> {
    const chatId = meta?.chat_id;
    await sendWhatsAppMessage(chatId, reply);
  }
}
Claude Code sees the channel notification as:
<channel source="whatsapp" chat_id="123" sender="Alice">
Alice: Can you review the PR?
</channel>
And can reply using the auto-registered channel-reply tool:
channel-reply({ channel_name: "whatsapp", text: "I'll review it now.", meta: { chat_id: "123" } })

Registration

In an App

@App({
  name: 'DevOps',
  channels: [DeployAlertChannel, ErrorChannel, WhatsAppChannel],
  tools: [/* your tools */],
})
class DevOpsApp {}

Enable at Server Level

@FrontMcp({
  info: { name: 'my-server', version: '1.0.0' },
  apps: [DevOpsApp],
  channels: { enabled: true },
})
export default class Server {}
Channels must be explicitly enabled with channels: { enabled: true } in the @FrontMcp config. Without this, channel declarations are ignored.

Wire Protocol

Channel notifications use the experimental notifications/claude/channel JSON-RPC method:
{
  "jsonrpc": "2.0",
  "method": "notifications/claude/channel",
  "params": {
    "content": "Deploy succeeded: v1.2.3",
    "meta": {
      "source": "deploy-alerts",
      "team": "platform",
      "status": "success"
    }
  }
}
The server advertises experimental: { 'claude/channel': {} } in its capabilities. Only sessions whose client capabilities include this extension receive channel notifications.

Session Isolation

Channel notifications are session-scoped to prevent data leaking between connected agents.

How It Works

  1. When a session initializes with claude/channel capability, it is auto-subscribed to all channels
  2. Notifications are only delivered to sessions subscribed to the specific channel
  3. Session-targeted events (job completions, agent results) go ONLY to the originating session
  4. Global events (webhooks, file changes) go to all subscribed sessions
  5. Channel subscriptions are cleaned up automatically on disconnect

Delivery Matrix

SourceTargetDelivery
Webhook, file-watcher, app-eventAll subscribersEvery subscribed session
Agent completionOriginating sessionONLY the session that triggered the agent
Job completionOriginating sessionONLY the session that triggered the job
Manual pushConfigurablesend() = all subscribers, sendToSession() = one session

Selective Subscriptions

Sessions can subscribe to specific channels instead of all:
// Subscribe to only deploy-alerts (not error-alerts)
scope.notifications.subscribeChannel(sessionId, 'deploy-alerts');

// Unsubscribe from a channel
scope.notifications.unsubscribeChannel(sessionId, 'deploy-alerts');
Session-targeted events from job/agent completions carry the originating sessionId in the event payload. If your custom source produces session-scoped data, pass targetSessionId to channel.pushNotification() to enforce isolation.

Security

Sender Gating

For chat bridges, always validate individual sender identity:
const ALLOWED = new Set(process.env['ALLOWED_SENDERS']?.split(',') ?? []);

async onEvent(payload: unknown): Promise<ChannelNotification> {
  const sender = extractSender(payload);
  if (!ALLOWED.has(sender)) {
    return { content: `Blocked: unverified sender`, meta: { verified: 'false' } };
  }
  // ...
}

Meta Key Restrictions

Meta keys must be valid identifiers (letters, digits, underscores). Invalid keys are rejected at startup:
// Good
meta: { env: 'production', build_id: '42' }

// Bad — will fail validation
meta: { 'my-env': 'production' }

@Channel Decorator

Full decorator API reference

ChannelContext

Context class API reference

ChannelRegistry

Registry API reference

Claude Code Channels

Official Claude Code channels specification