@Channel
@Channel declares a channel: a stream of events that your server pushes into a connected Claude Code session without being asked, like "billing is down" or "the customer replied". FrontMCP sends each event as a notifications/claude/channel message, and tells the model, in the server's instructions, that events arrive as <channel> tags. A two-way channel also gets a channel-reply tool, so the model can answer. Channels are an experimental Claude Code extension of MCP, and only reach clients that ask for them: a client that keeps a session asks when it connects, and a 2026-07-28 client asks on a subscriptions/listen stream. See what a client receives.
@Channel(options)
class MyChannel extends ChannelContext {
async onEvent(payload: unknown): Promise<ChannelNotification> { /* ... */ }
}
Reference
@Channel(options)
Apply @Channel to a class that extends ChannelContext, list the class in an app's channels array, and turn channels on in @FrontMcp:
import { Channel, ChannelContext, type ChannelNotification } from "@frontmcp/sdk";
@Channel({
name: "outages",
description: "Service outages as they happen",
source: { type: "app-event", event: "outage" },
meta: { team: "sre" },
})
export class OutagesChannel extends ChannelContext {
async onEvent(payload: unknown): Promise<ChannelNotification> {
const { service } = payload as { service: string };
return { content: `${service} is down`, meta: { service } };
}
}import { App, FrontMcp } from "@frontmcp/sdk";
import { OutagesChannel } from "./outages.channel";
@App({ id: "ops", name: "Ops", tools: [ReportOutage], channels: [OutagesChannel] })
export class OpsApp {}
@FrontMcp({ info: { name: "ops", version: "1.0.0" }, apps: [OpsApp], channels: { enabled: true } })
export default class Server {}Options
Required:
| Option | Type | Description |
|---|---|---|
name | string | The channel's name, unique on the server: a second channel with the same name stops the server from starting. It's sent as meta.source with every event, and it's what channel-reply takes. |
source | ChannelSourceConfig | Where events come from. See sources. |
Optional:
| Option | Type | Default | Description |
|---|---|---|---|
meta | Record<string, string> | none | Added to the meta of every event from this channel. Keys may only have letters, digits and underscores: "my-env" fails when the class is decorated, with Meta keys must be valid identifiers (letters, digits, underscores). |
twoWay | boolean | false | Registers the channel-reply tool, which calls this channel's onReply(). See letting the model reply. |
tools | tool classes | none | Tools that come with the channel, like one that sends a message through the same service. They're registered on the server and listed like any app's tools, and see the providers of the channel's app and of the server, as the channel does. (Changed in 1.9.3: before, they saw only the server's.) |
replay | { enabled: boolean; maxEvents?: number } | off | Keeps the last maxEvents events (default 50) on the channel. Nothing sends them automatically: see replaying missed events. |
description | string | none | For people reading the code. FrontMCP 1.9.3 doesn't send it to clients. |
tags | string[] | [] | Labels. Only the channels:list flow returns them. |
availableWhen | EntryAvailability | none | Registers the channel only where the server runs on a matching platform or runtime. Elsewhere it isn't registered: its events go nowhere, and a two-way channel's channel-reply tool isn't listed. See Offering an agent or a channel only where it works. (Changed in 1.9.2: before, it was ignored.) |
Sources
source.type decides what calls the channel's onEvent():
type | Fields | What runs onEvent(payload) | In FrontMCP 1.9.3 |
|---|---|---|---|
"app-event" | event: string | Your code, with channelEventBus.emit(event, payload) (emitting events). The payload is what you emit. | Works |
"manual" | none | Your code, with channel.handleEvent(payload). | Works |
"service" | service: string, a label | this.pushIncoming(payload), called from the connection your onConnect() opens. | Works |
"file-watcher" | paths: string[], events?: ("change" | "create" | "delete" | "rename")[] | The same as "service": FrontMCP calls onConnect() and watches nothing itself, so start the watcher there and call pushIncoming(). | Works |
"webhook" | path: string | An HTTP POST to path, on FrontMCP's Node server. The payload is { body, headers, method, query }, and the request gets 200 {"ok":true,"channel":"<name>"}. | Works on the Node server. createFetchHandler(), and so the Playground, doesn't serve the route: 404. |
"job-completion" | jobNames?: string[] | A job finishing. The payload is { jobName, jobId, status, durationMs, output, attempt, sessionId }, with output the result as JSON text, or error for a failed run. | Works. The event goes only to the session that ran the job. |
"agent-completion" | agentIds?: string[] | An agent finishing. The payload is { agentId, agentName, status, durationMs, output, runId, sessionId }. | Works, like "job-completion". |
Changed in 1.8.5: before, the last three sources never called onEvent(). A webhook's path must not be one of FrontMCP's own paths, and two channels can't share one, or the server doesn't start. While onEvent() handles a webhook, it and the tools it calls with this.callTool() are on the "http-trigger" surface. The webhook route was checked in Node, on a server started with bootstrap().
ChannelContext
The class your channel extends.
| Member | Description |
|---|---|
onEvent(payload) | Required. Turns a source's payload into the event to send: { content: string; meta?: Record<string, string> }, the event's text and its attributes. If it throws, nothing is sent, and the server logs Channel "…" failed to handle event. |
onReply(reply, meta?) | For two-way channels: called by channel-reply with the model's text and meta. If it throws, or the channel doesn't define it, channel-reply fails, and the model reads Error: Failed to send reply to channel "…": and the error's message. (Changed in 1.9: before, the model was told the reply was sent either way.) |
onConnect() | For "service" and "file-watcher" sources: called once, when the server starts. Open the connection here and call this.pushIncoming() for each incoming message. If it throws, the server doesn't start. |
onDisconnect() | For closing that connection: called once, when the server stops, or when dispose() of a create() or createDirect() server runs. (Changed in 1.9: before, dispose() didn't call it.) |
this.pushIncoming(payload) | protected. Runs onEvent(payload) on the same instance onConnect() ran on, and sends the result. Only works after onConnect(). |
this.get(token), this.tryGet(token) | Providers, from the channel's app and from the server. (Changed in 1.9.2: before, only the server's.) |
this.metadata | The channel's options. |
A service channel's onConnect(), pushIncoming(), onEvent() and onReply() all run on one long-lived instance, so it can keep the connection in a field. For other sources, FrontMCP makes a new instance for each event and each reply.
Emitting events from your code
The channel system belongs to the server's scope, which any tool, resource or prompt reaches as this.scope: this.scope.channels, this.scope.channelEventBus and this.scope.channelNotifications, all undefined unless channels.enabled is true. (Changed in 1.9: before, the ScopeEntry type didn't declare them, so code had to cast this.scope.)
| Member | Description |
|---|---|
channelEventBus.emit(event, payload) | Runs onEvent(payload) of every "app-event" channel whose source.event is event, and sends what each returns. |
channels.findByName(name) | The channel's ChannelInstance, or undefined. |
channels.getChannelInstances() | Every channel on the server. |
channel.handleEvent(payload, sessionId?) | Runs onEvent(payload) and sends the result, to one session if you pass its id. Returns the event, or null if onEvent() threw. |
channel.pushNotification(content, meta?, sessionId?) | Sends an event without calling onEvent(), to one session if you pass its id. Returns a promise (since 1.9). |
channel.replayBuffer | The events kept by replay, oldest first. |
channel.replayBufferedEvents(sessionId) | Sends the kept events to one session, each with meta.replayed: "true". Returns how many. |
channel.clearReplayBuffer() | Empties the buffer. |
channelNotifications.send(name, content, meta?) | Sends an event on the channel called name, without onEvent(). Returns a promise (since 1.9). |
this.scope.notifications.unsubscribeChannel(sessionId, name) stops one session receiving a channel's events, and subscribeChannel(sessionId, name) starts it again.
@FrontMcp({ channels })
| Field | Type | Description |
|---|---|---|
enabled | boolean | Required. Without enabled: true, the channels of every app are ignored: no channel-reply tool, no channel tools, and this.scope.channels is undefined. |
defaultMeta | Record<string, string> | Added to the meta of every event, before the channel's meta and the event's own, which win. (Changed in 1.9: before, only channelNotifications.send() added it.) |
@App({ channels }) is the only place to list channels. A plugin can't add one.
What a client receives
A client has to ask for channels, with experimental: { "claude/channel": {} } in its capabilities. A client that keeps a session (protocol versions before 2026-07-28) asks in its initialize request: FrontMCP subscribes its session to every channel, and each event arrives as a notification on the session's stream, the GET SSE stream for Streamable HTTP or stdout for stdio. A 2026-07-28 client has no session: it asks in a subscriptions/listen request, and the events arrive on that request's stream (see below).
{
"jsonrpc": "2.0",
"method": "notifications/claude/channel",
"params": {
"content": "billing is down",
"meta": { "team": "sre", "service": "billing", "source": "outages" }
}
}
metais the server'sdefaultMeta, then the channel'smeta, then themetathatonEvent()returned, thensource, the channel's name, which always wins.- The
initializeresult, and under 2026-07-28 theserver/discoverresult, listsexperimental: { "claude/channel": {} }in the server's capabilities, and addsEvents arrive as <channel> tags.to itsinstructions, followed byReply with the channel-reply tool.when a channel is two-way. (Changed in 1.9.3: before,server/discoverhad neither.) - A session whose client didn't ask for channels receives nothing, and neither does a
subscriptions/listenstream that didn't. - On a 2026-07-28 stream, each event also has the stream's
io.modelcontextprotocol/subscriptionIdin itsparams._meta. Events sent to one session, like"job-completion"events, never reach a stream. (Changed in 1.9.2: before, 2026-07-28 clients received no events at all.) - Events sent while nobody is subscribed are dropped, unless
replaykeeps them.
This was checked against a real FrontMCP server, over Streamable HTTP with a session (1.9.1) and over stdio (1.8.7), and on the 2026-07-28 stream with createFetchHandler() (1.9.3), as below.
What the Playground can show
The Playground's client speaks 2026-07-28 and doesn't open a subscriptions/listen stream, so no channel event reaches its Call tab. Everything else runs: channels register, onEvent() runs and returns its event, channel-reply calls onReply(), a service channel's onConnect() runs at startup, and channel tools are listed. Most examples below give their channels replay, and read replayBuffer to show each event exactly as FrontMCP would send it; one opens a stream of its own in a test.
Caveats
- Channels only reach clients that asked for
claude/channel: a session'sinitialize, like Claude Code's over stdio or Streamable HTTP, or a 2026-07-28subscriptions/listenrequest. - A
"webhook"source needs FrontMCP's Node server:createFetchHandler()doesn't serve its route."job-completion"and"agent-completion"events go only to the session that ran the job or agent, so a 2026-07-28 client, which has no session, never gets them. replayonly keeps events; nothing replays them unless you callreplayBufferedEvents().descriptionis accepted and has no effect.
Hooks on channel flows
ChannelSendHook and ChannelListHook hook the channels:send-notification flow (stages parseInput, resolveMeta, send, finalize) and the channels:list flow (listChannels, finalize). Every event goes through channels:send-notification, however it's sent: emit(), handleEvent(), pushNotification() or channelNotifications.send(). resolveMeta is where defaultMeta and the channel's meta are added. A session that asks for channels is subscribed to the ones the channels:list flow returns. See Hooking every event.
Changed in 1.9: before, FrontMCP never ran either flow itself, so their hooks only ran when your code started the flow.
Usage
Declaring a channel and emitting events
An "app-event" channel runs whenever your code emits its event. Here report_outage emits outage, and recent_alerts reads the channel's replay buffer to show what was sent:
import { Channel, ChannelContext, type ChannelNotification } from "@frontmcp/sdk";
@Channel({
name: "outages",
source: { type: "app-event", event: "outage" },
meta: { team: "sre" },
replay: { enabled: true, maxEvents: 20 },
})
export class OutagesChannel extends ChannelContext {
async onEvent(payload: unknown): Promise<ChannelNotification> {
const { service, severity } = payload as { service: string; severity: string };
return { content: `${service} is down`, meta: { service, severity } };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
A subscribed session, like Claude Code's over stdio or Streamable HTTP, would receive this event as a notifications/claude/channel message with this content and meta: the server's defaultMeta, the channel's meta, what onEvent() returned, and source. Take channels: { enabled: true } out and emit() isn't reached: channelEventBus is undefined, and recent_alerts finds no channel.
Receiving events on a 2026-07-28 client
A 2026-07-28 client has no session to subscribe, so it opens a subscriptions/listen request and keeps reading its response, a stream of notifications. With experimental: { "claude/channel": {} } in that request's client capabilities, the stream carries every channel's events. The Playground's client doesn't open one, so this example's test does, through createFetchHandler(), the way a client's requests reach a server:
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./main";
const asksForChannels = { experimental: { "claude/channel": {} } };
test("a stream that asks for channels gets each event", async () => {
const handler = await FrontMcpInstance.createFetchHandler(config);
const stream = (await handler(request("subscriptions/listen", { notifications: {} }, asksForChannels))).body!.getReader();
expect((await nextMessage(stream))?.method).toBe("notifications/subscriptions/acknowledged");
await handler(request("tools/call", { name: "report_outage", arguments: { service: "billing" } }));
expect(await nextMessage(stream)).toEqual({
jsonrpc: "2.0",
method: "notifications/claude/channel",
params: { content: "billing is down", meta: { team: "sre", source: "outages" }, _meta: { "io.modelcontextprotocol/subscriptionId": 1 } },
});
void stream.cancel(); // the client goes away
});
test("a stream that doesn't ask gets none", async () => {
const handler = await FrontMcpInstance.createFetchHandler(config);
const stream = (await handler(request("subscriptions/listen", { notifications: {} }))).body!.getReader();
expect((await nextMessage(stream))?.method).toBe("notifications/subscriptions/acknowledged");
await handler(request("tools/call", { name: "report_outage", arguments: { service: "email" } }));
expect(await nextMessage(stream)).toBeNull();
void stream.cancel();
});
test("`server/discover` says the server has channels", async ({ mcp }) => {
const { result } = (await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "server/discover" })) as any;
expect(result.capabilities.experimental).toEqual({ "claude/channel": {} });
expect(result.instructions).toBe("Events arrive as <channel> tags.");
});
// What a 2026-07-28 client sends: the method, in the body and a header, and its capabilities in `_meta`.
function request(method: string, params: Record<string, unknown>, capabilities: Record<string, unknown> = {}) {
return new Request("https://ops.example/", {
method: "POST",
headers: {
"content-type": "application/json",
accept: "application/json, text/event-stream",
"mcp-protocol-version": "2026-07-28",
"mcp-method": method,
...(method === "tools/call" ? { "mcp-name": String(params.name) } : {}),
},
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method,
params: { ...params, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientCapabilities": capabilities } },
}),
});
}
// The next message on the stream, or null when none comes within 200 ms.
async function nextMessage(stream: ReadableStreamDefaultReader<Uint8Array>) {
const chunk = await Promise.race([stream.read(), new Promise<null>((resolve) => setTimeout(() => resolve(null), 200))]);
if (!chunk || chunk.done) return null;
const data = new TextDecoder().decode(chunk.value).split("\n").find((line) => line.startsWith("data: "));
return data ? JSON.parse(data.slice("data: ".length)) : null;
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
The stream starts with notifications/subscriptions/acknowledged, then carries each event as notifications/claude/channel, with the stream's id, the request's id, as subscriptionId. A stream that didn't ask for channels gets none, and neither does any stream for an event sent to one session. A client learns that the server has channels from server/discover, as the last test shows: it lists the claude/channel capability, and its instructions tell the model about <channel> tags, after the server's own instructions when it has some. (Changed in 1.9.3: before, server/discover had neither, so a client had to know to ask, and the model learned about the tags only from your own instructions. Changed in 1.9.2: before, a 2026-07-28 client received no channel events.)
Letting the model reply
With twoWay: true, FrontMCP registers a channel-reply tool, with channel_name, text and an optional meta. It calls the channel's onReply(), so the reply can go back to wherever the event came from:
import { Channel, ChannelContext, type ChannelNotification } from "@frontmcp/sdk";
// Stands in for the chat service the replies go to
export const sentReplies: { chatId?: string; text: string }[] = [];
@Channel({ name: "customer-chat", source: { type: "manual" }, twoWay: true })
export class CustomerChat extends ChannelContext {
async onEvent(payload: unknown): Promise<ChannelNotification> {
const { chatId, text } = payload as { chatId: string; text: string };
return { content: text, meta: { chat_id: chatId } };
}
async onReply(reply: string, meta?: Record<string, string>) {
sentReplies.push({ chatId: meta?.chat_id, text: reply });
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
The model learns which meta to send back from the event: onEvent() put chat_id in its meta. One channel-reply tool serves every two-way channel on the server. When onReply() throws, or a two-way channel has none, the call fails with the error, as the fourth test shows, so the model knows its reply didn't go out. Changed in 1.9: before, channel-reply said the reply was sent either way. The server's instructions tell the model to reply with channel-reply, as the last test shows.
Connecting to a service
A "service" channel keeps a connection open. onConnect() runs once when the server starts; each incoming message goes through this.pushIncoming(), which runs onEvent(). The channel's tools are the other direction: tools the model calls to send messages. Here a stand-in chat service answers every message it's sent:
import { Channel, ChannelContext, Tool, ToolContext, z, type ChannelNotification } from "@frontmcp/sdk";
import { ChatService } from "./chat-service";
@Tool({
name: "send_chat_message",
description: "Send a chat message to a customer. Their answer arrives on the customer-chat channel.",
inputSchema: { to: z.string(), text: z.string() },
})
export class SendChatMessage extends ToolContext {
async execute({ to, text }: { to: string; text: string }) {
this.get(ChatService).send(to, text);
return { sent: true, to };
}
}
@Channel({
name: "customer-chat",
source: { type: "service", service: "chat" },
tools: [SendChatMessage],
replay: { enabled: true },
})
export class CustomerChat extends ChannelContext {
// Runs once, when the server starts
async onConnect() {
this.get(ChatService).onMessage((from, text) => this.pushIncoming({ from, text }));
}
// Runs once, when the server stops
async onDisconnect() {
this.get(ChatService).close();
}
async onEvent(payload: unknown): Promise<ChannelNotification> {
const { from, text } = payload as { from: string; text: string };
return { content: `${from}: ${text}`, meta: { sender: from } };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
ChatService is registered on the app, so the channel and its tool get the same instance: the tool sends through it, and onConnect() listens on it, as the second test shows. (Changed in 1.9.3: before, the tools in a channel's tools saw only the server's providers, so ChatService had to go on @FrontMcp, where it still works.) In a real connector, onConnect() would open a WebSocket or start polling, onDisconnect() would close it, and onEvent() would check who sent each message before passing it to the model.
Reacting to a job or an agent finishing
A "job-completion" channel runs when one of its jobNames finishes, and an "agent-completion" channel when one of its agentIds does. Each gets the run's outcome as its payload:
import { Channel, ChannelContext, type ChannelNotification } from "@frontmcp/sdk";
/** Every payload the two channels got, for the tests. */
export const received: object[] = [];
@Channel({ name: "exports", source: { type: "job-completion", jobNames: ["export-tickets"] } })
export class ExportsChannel extends ChannelContext {
async onEvent(payload: unknown): Promise<ChannelNotification> {
received.push(payload as object);
const run = payload as { jobName: string; status: string };
return { content: `${run.jobName} finished: ${run.status}` };
}
}
@Channel({ name: "triages", source: { type: "agent-completion", agentIds: ["triage"] } })
export class TriagesChannel extends ChannelContext {
async onEvent(payload: unknown): Promise<ChannelNotification> {
received.push(payload as object);
const run = payload as { agentName: string; output?: string };
return { content: `${run.agentName} answered: ${run.output}` };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
The event goes only to the session in sessionId, the one that ran the job or agent. The Playground's 2026-07-28 client has none, so it receives nothing, but onEvent() still runs.
Sending to one session
By default an event goes to every subscribed session. Pass a session id to handleEvent() or pushNotification() to send it to one session only, like the one whose tool call caused it. For a client that keeps a session (protocol versions before 2026-07-28), this.context.sessionId is that session's id; under 2026-07-28 it's a new id for every request, and an event sent to one session never reaches a subscriptions/listen stream. So, for a legacy client:
import { Tool, ToolContext, z } from "@frontmcp/sdk";
@Tool({ name: "start_export", description: "Export tickets; you'll be told when the file is ready", inputSchema: { month: z.string() } })
export class StartExport extends ToolContext {
async execute({ month }: { month: string }) {
const exports = this.scope.channels?.findByName("exports");
const sessionId = this.context.sessionId;
buildExport(month).then((url) => exports?.pushNotification(`The ${month} export is ready: ${url}`, { month }, sessionId));
return { started: month };
}
}To stop one session receiving a channel, call this.scope.notifications.unsubscribeChannel(sessionId, "exports"); subscribeChannel() starts it again. Sessions only exist for clients before 2026-07-28, so this needs a real server and a client like Claude Code: the Playground can't show it. On a real server it was checked with two sessions: the targeted event reached only the first, and after unsubscribeChannel() the second stopped receiving the channel.
Hooking every event
Every event goes through the channels:send-notification flow, so a plugin can see or change each one with ChannelSendHook. Here a plugin keeps an audit log of what was sent, and adds the time to each event's meta once the channel's and the server's are in:
import { ChannelSendHook, DynamicPlugin, FlowCtxOf, Plugin } from "@frontmcp/sdk";
export const audit: string[] = [];
@Plugin({ name: "channel-audit", description: "Logs every channel event, and stamps it with the time" })
export class ChannelAudit extends DynamicPlugin<object> {
@ChannelSendHook.Did("resolveMeta")
async stamp(ctx: FlowCtxOf<"channels:send-notification">) {
ctx.state.set("meta", { ...ctx.state.meta, sent_at: "2026-10-05T09:00:00Z" });
}
@ChannelSendHook.Will("send")
async record(ctx: FlowCtxOf<"channels:send-notification">) {
audit.push(`${ctx.state.channelName}: ${ctx.state.content}`);
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Changed in 1.9: before, FrontMCP never ran this flow for an event, so these hooks only ran when your own code started the flow.
Replaying missed events
Events sent while no session is subscribed are lost. With replay, the channel keeps the last maxEvents, and replayBufferedEvents(sessionId) sends them to a session, marked replayed: "true". FrontMCP doesn't call it for you, so give the model a tool to catch up with:
import { App, Channel, ChannelContext, FrontMcp, Tool, ToolContext, z, type ChannelNotification } from "@frontmcp/sdk";
@Channel({ name: "outages", source: { type: "app-event", event: "outage" }, replay: { enabled: true, maxEvents: 2 } })
export class OutagesChannel extends ChannelContext {
async onEvent(payload: unknown): Promise<ChannelNotification> {
return { content: `${(payload as { service: string }).service} is down` };
}
}
@Tool({ name: "report_outage", description: "Report that a service is down", inputSchema: { service: z.string() } })
export class ReportOutage extends ToolContext {
async execute({ service }: { service: string }) {
this.scope.channelEventBus?.emit("outage", { service });
return { reported: service };
}
}
@Tool({ name: "catch_up", description: "Resend the latest outage alerts to this session", inputSchema: {} })
export class CatchUp extends ToolContext {
async execute() {
const channel = this.scope.channels?.findByName("outages");
const kept = [...(channel?.replayBuffer ?? [])].map((event) => event.content);
const resent = channel?.replayBufferedEvents(this.context.sessionId ?? "") ?? 0;
return { kept, resent };
}
}
@App({ id: "ops", name: "Ops", tools: [ReportOutage, CatchUp], channels: [OutagesChannel] })
export class OpsApp {}
@FrontMcp({ info: { name: "ops", version: "1.0.0" }, apps: [OpsApp], channels: { enabled: true } })
export default class Server {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
In the Playground, resent is still counted, but nothing arrives: the Playground's client has no session. On a real server, a Claude Code session that calls catch_up gets both events, each with meta.replayed set to "true".
Troubleshooting
Claude Code never shows my channel's events
Check, in order:
@FrontMcp({ channels: { enabled: true } })is set, and the channel is in an app'schannels.- The client asked for channels, with
experimental: { "claude/channel": {} }: in itsinitialize, for a client that keeps a session, or in itssubscriptions/listenrequest, for a 2026-07-28 client. The Playground's client opens no stream, so it never receives channel events. - The channel's
availableWhen, if it has one, matches where the server runs: otherwise the channel isn't registered. - The source can reach the client: a
"webhook"needs the Node server, and"job-completion"and"agent-completion"events go only to the session that ran the job or agent. onEvent()didn't throw. The server's log saysChannel "…" failed to handle eventwhen it did.- The session is still subscribed:
unsubscribeChannel()stops a channel for one session.
Meta keys must be valid identifiers (letters, digits, underscores)
A key in the channel's meta has another character, like "my-env". Rename it, my_env. The check runs when the class is decorated, so the server doesn't start.
Duplicate channel name "…" — each channel must have a unique name.
Two channels on the server have the same name, maybe in different apps. Channel names are server-wide.
Failed to send reply to channel "…"
channel-reply failed because the channel's onReply() threw, with its message after the colon, or because the two-way channel doesn't define one: Channel "…" has twoWay: true but onReply() is not implemented. Implement onReply(), or fix what it calls. Before 1.9, FrontMCP logged Channel "…" failed to handle reply and told the model the reply was sent.
POST /hooks/… returns 404
The server runs on createFetchHandler(), which doesn't serve webhook routes and answers {"error":"Not Found","entryPaths":["/"]}, or the request isn't a POST. Serve the channel from FrontMCP's Node server, or emit the event from your own route, with an "app-event" or "manual" channel. Before 1.8.5, no server registered the route.
Channel "…" has job-completion source but no job emitter available
FrontMCP 1.8.4 logged this at startup, and never wired "job-completion" or "agent-completion" sources. Since 1.8.5 they work: see Reacting to a job or an agent finishing.
this.scope.channels is undefined
Channels aren't enabled. Set channels: { enabled: true } on @FrontMcp. The same goes for channelEventBus and channelNotifications.
TypeScript says Property 'channelEventBus' does not exist on type 'ScopeEntry'
The project has a FrontMCP older than 1.9, whose ScopeEntry type didn't declare the channel members of the scope. Since 1.9 it declares channels, channelEventBus and channelNotifications: upgrade, and the casts older code needed can go.
Provider "…" is not available in a channel
The channel asked for a provider that isn't registered on its app or on the server, like one on another app: register it on the channel's app, or in @FrontMcp({ providers }) to share it between apps. Before 1.9.2, a channel didn't see its own app's providers either.
A channel's tools find providers in the same places. Before 1.9.3 they saw only the server's, so on an older FrontMCP, register a provider they use in @FrontMcp({ providers }).