# @Channel

> Push events into a connected Claude Code session as they happen, with @Channel. Sources, ChannelContext, emitting events from your code, two-way replies, hooks, and exactly what a client receives.

Source: https://frontmcp.dev/reference/sdk/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](#what-a-client-receives).

```ts
@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`:

```ts outages.channel.ts
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 } };
  }
}
```

```ts main.ts
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 {}
```

[See more examples below.](#usage)

#### 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](#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()`](#channelcontext). See [letting the model reply](#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](#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](https://frontmcp.dev/reference/server/environment#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](#emitting-events-from-your-code)). 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()`](#channelcontext) 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()`](https://frontmcp.dev/reference/sdk/create-fetch-handler), 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](https://frontmcp.dev/reference/sdk/context#getcallsurface-and-getrunningtool). 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](https://frontmcp.dev/reference/sdk/provider), 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](#receiving-events-on-a-2026-07-28-client)).

```json
{
  "jsonrpc": "2.0",
  "method": "notifications/claude/channel",
  "params": {
    "content": "billing is down",
    "meta": { "team": "sre", "service": "billing", "source": "outages" }
  }
}
```

- `meta` is the server's `defaultMeta`, then the channel's `meta`, then the `meta` that `onEvent()` returned, then `source`, the channel's name, which always wins.
- The `initialize` result, and under 2026-07-28 the `server/discover` result, lists `experimental: { "claude/channel": {} }` in the server's capabilities, and adds `Events arrive as <channel> tags.` to its `instructions`, followed by ` Reply with the channel-reply tool.` when a channel is two-way. (Changed in 1.9.3: before, `server/discover` had neither.)
- A session whose client didn't ask for channels receives nothing, and neither does a `subscriptions/listen` stream that didn't.
- On a **2026-07-28** stream, each event also has the stream's `io.modelcontextprotocol/subscriptionId` in its `params._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 `replay` keeps 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](#receiving-events-on-a-2026-07-28-client).

#### 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](#receiving-events-on-a-2026-07-28-client) opens a stream of its own in a test.

#### Caveats

- Channels only reach clients that asked for `claude/channel`: a session's `initialize`, like Claude Code's over stdio or Streamable HTTP, or a 2026-07-28 `subscriptions/listen` request.
- 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.
- `replay` only keeps events; nothing replays them unless you call `replayBufferedEvents()`.
- `description` is 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](#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:

```ts outages.channel.ts active
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 } };
  }
}
```

```ts tools.ts
import { Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({
  name: "report_outage",
  description: "Report that a service is down, and alert everyone connected",
  inputSchema: { service: z.string(), severity: z.enum(["minor", "major"]).default("major") },
})
export class ReportOutage extends ToolContext {
  async execute({ service, severity }: { service: string; severity: "minor" | "major" }) {
    this.scope.channelEventBus?.emit("outage", { service, severity });
    return { reported: service };
  }
}

@Tool({ name: "recent_alerts", description: "Show the outage alerts sent so far", inputSchema: {} })
export class RecentAlerts extends ToolContext {
  async execute() {
    const channel = this.scope.channels?.findByName("outages");
    return { alerts: [...(channel?.replayBuffer ?? [])] };
  }
}
```

```ts main.ts
import { App, FrontMcp } from "@frontmcp/sdk";
import { OutagesChannel } from "./outages.channel";
import { RecentAlerts, ReportOutage } from "./tools";

@App({ id: "ops", name: "Ops", tools: [ReportOutage, RecentAlerts], channels: [OutagesChannel] })
export class OpsApp {}

@FrontMcp({
  info: { name: "ops", version: "1.0.0" },
  apps: [OpsApp],
  channels: { enabled: true, defaultMeta: { env: "production" } },
})
export default class Server {}
```

```ts outages.test.ts
import { test, expect } from "@frontmcp/testing";

test("emitting the event runs onEvent, and its result is the event sent", async ({ mcp }) => {
  await mcp.tools.call("report_outage", { service: "billing" });
  expect((await mcp.tools.call("recent_alerts", {})).json().alerts).toEqual([
    { content: "billing is down", meta: { env: "production", team: "sre", service: "billing", severity: "major", source: "outages" } },
  ]);
});

test("a channel adds no tools unless it's two-way", async ({ mcp }) => {
  const names = (await mcp.tools.list()).map((t: { name: string }) => t.name);
  expect(names.sort()).toEqual(["recent_alerts", "report_outage"]);
});
```

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()`](https://frontmcp.dev/reference/sdk/create-fetch-handler), the way a client's requests reach a server:

```ts listen.test.ts active
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;
}
```

```ts outages.channel.ts
import { Channel, ChannelContext, type ChannelNotification } from "@frontmcp/sdk";

@Channel({ name: "outages", source: { type: "app-event", event: "outage" }, meta: { team: "sre" } })
export class OutagesChannel extends ChannelContext {
  async onEvent(payload: unknown): Promise<ChannelNotification> {
    return { content: `${(payload as { service: string }).service} is down` };
  }
}
```

```ts main.ts
import { App, FrontMcp, Tool, ToolContext, z } from "@frontmcp/sdk";
import { OutagesChannel } from "./outages.channel";

@Tool({ name: "report_outage", description: "Report that a service is down, and alert everyone listening", inputSchema: { service: z.string() } })
export class ReportOutage extends ToolContext {
  async execute({ service }: { service: string }) {
    this.scope.channelEventBus?.emit("outage", { service });
    return { reported: service };
  }
}

@App({ id: "ops", name: "Ops", tools: [ReportOutage], channels: [OutagesChannel] })
export class OpsApp {}

export const config = {
  info: { name: "ops", version: "1.0.0" },
  apps: [OpsApp],
  channels: { enabled: true },
};

@FrontMcp(config)
export default class Server {}
```

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:

```ts chat.channel.ts active
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 });
  }
}
```

```ts main.ts
import { App, Channel, ChannelContext, FrontMcp, Tool, ToolContext, type ChannelNotification } from "@frontmcp/sdk";
import { CustomerChat, sentReplies } from "./chat.channel";

@Channel({ name: "outages", source: { type: "app-event", event: "outage" } })
export class OutagesChannel extends ChannelContext {
  async onEvent(payload: unknown): Promise<ChannelNotification> {
    return { content: String(payload) };
  }
}

// 🚩 two-way, with no onReply()
@Channel({ name: "status-page", source: { type: "manual" }, twoWay: true })
export class StatusPageChannel extends ChannelContext {
  async onEvent(payload: unknown): Promise<ChannelNotification> {
    return { content: String(payload) };
  }
}

@Tool({ name: "sent_replies", description: "Show the replies sent to customers", inputSchema: {} })
export class SentReplies extends ToolContext {
  async execute() {
    return { replies: [...sentReplies] };
  }
}

@App({ id: "support", name: "Support", tools: [SentReplies], channels: [CustomerChat, OutagesChannel, StatusPageChannel] })
export class SupportApp {}

@FrontMcp({ info: { name: "support", version: "1.0.0" }, apps: [SupportApp], channels: { enabled: true } })
export default class Server {}
```

```ts reply.test.ts
import { test, expect } from "@frontmcp/testing";

test("channel-reply calls onReply with the text and meta", async ({ mcp }) => {
  const result = await mcp.tools.call("channel-reply", { channel_name: "customer-chat", text: "We're on it.", meta: { chat_id: "C-42" } });
  expect(result.text()).toBe('Reply sent to channel "customer-chat" successfully.');
  expect((await mcp.tools.call("sent_replies", {})).json().replies).toEqual([{ chatId: "C-42", text: "We're on it." }]);
});

test("a one-way channel refuses replies", async ({ mcp }) => {
  const result = await mcp.tools.call("channel-reply", { channel_name: "outages", text: "Thanks" });
  expect(result).toBeError();
  expect(result.text()).toBe('Error: Channel "outages" does not support replies (one-way only).');
});

test("an unknown channel lists the ones that take replies", async ({ mcp }) => {
  const result = await mcp.tools.call("channel-reply", { channel_name: "chat", text: "Hi" });
  expect(result.text()).toBe('Error: Channel "chat" not found. Available channels: customer-chat, status-page');
});

test("a two-way channel without onReply fails the reply", async ({ mcp }) => {
  const result = await mcp.tools.call("channel-reply", { channel_name: "status-page", text: "Fixed." });
  expect(result).toBeError();
  expect(result.text()).toBe(
    'Error: Failed to send reply to channel "status-page": Channel "status-page" has twoWay: true but onReply() is not implemented. Override onReply() to forward replies to the external system.',
  );
});

test("the server's instructions say to reply with channel-reply", async ({ mcp }) => {
  const { result } = (await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "server/discover" })) as any;
  expect(result.instructions).toBe("Events arrive as <channel> tags. Reply with the channel-reply tool.");
});
```

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:

```ts chat.channel.ts active
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 } };
  }
}
```

```ts chat-service.ts
import { Provider } from "@frontmcp/sdk";

type Listener = (from: string, text: string) => void;

// Stands in for a chat service's client. It answers every message it's sent.
@Provider({ name: "ChatService" })
export class ChatService {
  static closed = 0;
  private listeners: Listener[] = [];

  close() {
    ChatService.closed += 1;
    this.listeners = [];
  }

  onMessage(listener: Listener) {
    this.listeners.push(listener);
  }

  send(to: string, text: string) {
    for (const listener of this.listeners) listener(to, `Got your message: "${text}"`);
  }
}
```

```ts main.ts
import { App, FrontMcp, Tool, ToolContext } from "@frontmcp/sdk";
import { ChatService } from "./chat-service";
import { CustomerChat } from "./chat.channel";

@Tool({ name: "recent_chat", description: "Show the chat messages that arrived", inputSchema: {} })
export class RecentChat extends ToolContext {
  async execute() {
    return { messages: [...(this.scope.channels?.findByName("customer-chat")?.replayBuffer ?? [])] };
  }
}

@App({ id: "support", name: "Support", tools: [RecentChat], channels: [CustomerChat], providers: [ChatService] })
export class SupportApp {}

export const config = {
  info: { name: "support", version: "1.0.0" },
  apps: [SupportApp],
  channels: { enabled: true },
};

@FrontMcp(config)
export default class Server {}
```

```ts chat.test.ts
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { ChatService } from "./chat-service";
import { config } from "./main";

test("the channel's tool is listed with the app's", async ({ mcp }) => {
  expect(await mcp.tools.list()).toContainTool("send_chat_message");
});

test("an incoming message goes through pushIncoming and onEvent", async ({ mcp }) => {
  await mcp.tools.call("send_chat_message", { to: "dana", text: "Is the invoice sorted?" });
  expect((await mcp.tools.call("recent_chat", {})).json().messages).toEqual([
    { content: 'dana: Got your message: "Is the invoice sorted?"', meta: { sender: "dana", source: "customer-chat" } },
  ]);
});

test("dispose() runs onDisconnect", async () => {
  const server = await FrontMcpInstance.createDirect(config);
  const before = ChatService.closed;
  await server.dispose();
  expect(ChatService.closed - before).toBe(1);
});

```

`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:

```ts completions.channel.ts active
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}` };
  }
}
```

```ts main.ts
import { Agent, AgentContext, App, FrontMcp, Job, JobContext, z } from "@frontmcp/sdk";
import { ExportsChannel, TriagesChannel } from "./completions.channel";

@Job({ name: "export-tickets", inputSchema: {}, outputSchema: { rows: z.number() } })
export class ExportTickets extends JobContext {
  async execute() {
    return { rows: 43 };
  }
}

// A stand-in model, which always answers "High".
@Agent({ name: "triage", inputSchema: {}, llm: { adapter: { completion: async () => ({ content: "High", finishReason: "stop" as const }) } } })
export class Triage extends AgentContext {}

@App({ id: "ops", name: "Ops", jobs: [ExportTickets], agents: [Triage], channels: [ExportsChannel, TriagesChannel] })
export class OpsApp {}

@FrontMcp({ info: { name: "ops", version: "1.0.0" }, apps: [OpsApp], channels: { enabled: true } })
export default class Server {}
```

```ts completions.test.ts
import { test, expect } from "@frontmcp/testing";
import { received } from "./completions.channel";

test("a finished job runs the job-completion channel", async ({ mcp }) => {
  received.length = 0;
  await mcp.tools.call("execute_job", { name: "export-tickets", input: {} });
  expect(received).toEqual([
    { jobName: "export-tickets", jobId: "export-tickets", status: "success", durationMs: expect.any(Number), output: '{"rows":43}', attempt: 1, sessionId: expect.any(String) },
  ]);
});

test("a finished agent runs the agent-completion channel", async ({ mcp }) => {
  received.length = 0;
  await mcp.tools.call("invoke_triage", {});
  expect(received).toMatchObject([{ agentId: "triage", agentName: "triage", status: "success", output: '{"response":"High"}' }]);
});
```

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:

```ts export.tool.ts
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:

```ts audit.plugin.ts active
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}`);
  }
}
```

```ts main.ts
import { App, Channel, ChannelContext, FrontMcp, Tool, ToolContext, z, type ChannelNotification } from "@frontmcp/sdk";
import { ChannelAudit } from "./audit.plugin";

@Channel({ name: "outages", source: { type: "app-event", event: "outage" }, meta: { team: "sre" }, replay: { enabled: true } })
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: "update_outage", description: "Tell everyone connected how an outage is going", inputSchema: { text: z.string() } })
export class UpdateOutage extends ToolContext {
  async execute({ text }: { text: string }) {
    await this.scope.channelNotifications?.send("outages", text);
    return { sent: [...(this.scope.channels?.findByName("outages")?.replayBuffer ?? [])] };
  }
}

@App({ id: "ops", name: "Ops", tools: [ReportOutage, UpdateOutage], channels: [OutagesChannel], plugins: [ChannelAudit] })
export class OpsApp {}

@FrontMcp({ info: { name: "ops", version: "1.0.0" }, apps: [OpsApp], channels: { enabled: true } })
export default class Server {}
```

```ts audit.test.ts
import { test, expect } from "@frontmcp/testing";
import { audit } from "./audit.plugin";

test("the hooks run for emit() and for send()", async ({ mcp }) => {
  await mcp.tools.call("report_outage", { service: "billing" });
  const result = await mcp.tools.call("update_outage", { text: "billing is being looked at" });
  expect(audit).toEqual(["outages: billing is down", "outages: billing is being looked at"]);
  expect(result.json().sent).toEqual([
    { content: "billing is down", meta: { team: "sre", sent_at: "2026-10-05T09:00:00Z", source: "outages" } },
    { content: "billing is being looked at", meta: { team: "sre", sent_at: "2026-10-05T09:00:00Z", source: "outages" } },
  ]);
});
```

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:

```ts main.ts active
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 {}
```

```ts replay.test.ts
import { test, expect } from "@frontmcp/testing";

test("the buffer keeps the last maxEvents events", async ({ mcp }) => {
  for (const service of ["search", "billing", "email"]) await mcp.tools.call("report_outage", { service });
  expect((await mcp.tools.call("catch_up", {})).json().kept).toEqual(["billing is down", "email is down"]);
});
```

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:

1. `@FrontMcp({ channels: { enabled: true } })` is set, and the channel is in an app's `channels`.
2. The client asked for channels, with `experimental: { "claude/channel": {} }`: in its `initialize`, for a client that keeps a session, or in its `subscriptions/listen` request, for a 2026-07-28 client. The Playground's client opens no stream, so it never receives channel events.
3. The channel's `availableWhen`, if it has one, matches where the server runs: otherwise the channel isn't registered.
4. 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.
5. `onEvent()` didn't throw. The server's log says `Channel "…" failed to handle event` when it did.
6. 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()`](https://frontmcp.dev/reference/sdk/create-fetch-handler), 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](#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 })`.
