# Handle mentions, messages, and subscriptions

> Wire the ten channel handlers — onMention, onMessage, onThreadStarted, onWelcome, onCommand, onInteraction, onInterrupt, onReaction, onModalSubmit, onModalClose — reply on mention with thread.runAgent(), forward contentParts explicitly, and use subscribe()/isSubscribed() to answer every message in an invited conversation.

- Repository: CopilotKit/channels-sdk
- GitHub: https://github.com/CopilotKit/channels-sdk
- Human docs: https://grok-wiki.com/public/docs/copilotkit-channels-sdk-4c947e3e6161
- Complete Markdown: https://grok-wiki.com/public/docs/copilotkit-channels-sdk-4c947e3e6161/llms-full.txt

## Source Files

- `.agents/skills/build-channels-agent/SKILL.md`
- `examples/minimal-channel/lib/channel.ts`
- `examples/minimal-channel/README.md`
- `README.md`

---

---
title: "Handle mentions, messages, and subscriptions"
description: "Wire the ten channel handlers — onMention, onMessage, onThreadStarted, onWelcome, onCommand, onInteraction, onInterrupt, onReaction, onModalSubmit, onModalClose — reply on mention with thread.runAgent(), forward contentParts explicitly, and use subscribe()/isSubscribed() to answer every message in an invited conversation."
---

A `Channel` returned by `createChannel()` exposes ten handler registration methods. Attach them to the channel object before the runtime starts it — creating the `CopilotRuntime` listener is what activates the Channel, so all handlers must be wired first. Every conversation-scoped handler receives a `thread` handle, and the most common wiring is three lines: run the agent on mention, mark the conversation subscribed, and gate `onMessage` on that subscription so the agent answers every message in a conversation it was invited into.

## The ten handlers

| Handler | Fires when | Handler receives |
| --- | --- | --- |
| `channel.onMention(fn)` | the agent is @-mentioned (takes priority over `onMessage`) | `{ thread, message }` |
| `channel.onMessage(fn)` | any message the Channel sees | `{ thread, message }` |
| `channel.onThreadStarted(fn)` | a conversation surface opens (e.g. the Slack assistant pane) | `{ thread, user, actor }` |
| `channel.onWelcome(fn)` | the app is installed / a conversation is activated | `{ thread, user, actor, platform }` |
| `channel.onCommand(name, fn)` | a slash command runs | `CommandContext` |
| `channel.onInteraction<T>(id, fn)` | a bound action fires (explicit binding) | `InteractionContext<T>` |
| `channel.onInterrupt<T>(event, fn)` | the agent pauses mid-run | `{ payload, thread, user, actor }` |
| `channel.onReaction([emoji,] fn)` | an emoji reaction is added or removed | `ReactionEvent` |
| `channel.onModalSubmit(id, fn)` | a modal is submitted (return `{ errors }` to keep it open) | `ModalSubmitEvent` |
| `channel.onModalClose(id, fn)` | a modal is dismissed | `ModalCloseEvent` |

<Warning>
`onMention` and `onMessage` receive `{ thread, message }` only — there is **no `user`** in their context. Reach the caller through the message, or use a handler that exposes `user` (`onThreadStarted`, `onWelcome`, or a tool's `ChannelToolContext`).
</Warning>

Two constraints apply to every handler:

- Handlers must return `void | Promise<void>`. A concise arrow that returns `thread.post(...)` fails under `strict`, because `post` returns a `MessageRef`. Use a block body: `async ({ thread }) => { await thread.post(…); }`.
- There is no generic event API. `channel.on("message", …)` does not exist — use the named handlers above.

## Reply on mention with thread.runAgent()

`thread.runAgent()` drives the agent's full run / tool-call / interrupt loop and renders each step as it streams. When `prompt` is omitted, it defaults to the inbound `message.contentParts` or `message.text`, so a bare call is the correct mention handler:

```ts title="Reply on mention"
channel.onMention(async ({ thread }) => {
  await thread.runAgent();
});
```

`runAgent` accepts an input object when you need to shape the run:

<ParamField body="prompt" type="string | AgentContentPart[]">
  The turn input. Defaults to the inbound message's `contentParts` or `text` when omitted.
</ParamField>

<ParamField body="context" type="ContextEntry[]">
  `{ description, value }` pairs injected into the agent's prompt for this run only.
</ParamField>

<ParamField body="tools" type="ChannelTool[]">
  Extra tools available for this run, in addition to those registered on the Channel.
</ParamField>

<ParamField body="transcript" type="boolean">
  Auto-bridges cross-platform transcripts (inject history → append the user turn → run → append the reply). Do not also append the same turns via `channel.transcripts.append`.
</ParamField>

<ParamField body="memory" type="{ user?, project? }">
  Per-run Intelligence Memory grant, each scope `"none" | "read" | "read-write"`. Omitting `memory` disables Memory entirely — there is no implicit access.
</ParamField>

## Forward contentParts explicitly

Pass `prompt` explicitly only when the input is not in reconstructed history — slash-command arguments, or when you combine text and attachments yourself. To forward a message with attachments, merge `message.text` and `message.contentParts` into one content-part array:

```ts title="Forward text + attachments"
channel.onMessage(async ({ thread, message }) => {
  await thread.runAgent({
    prompt: message.contentParts?.length
      ? [
          ...(message.text ? [{ type: "text" as const, text: message.text }] : []),
          ...message.contentParts,
        ]
      : message.text,
    context: [{ description: "Originating platform", value: message.platform }],
  });
});
```

## The subscribe pattern

`thread.subscribe()`, `thread.unsubscribe()`, and `thread.isSubscribed()` manage a persisted per-conversation flag. Use it to answer every message in a conversation the agent was invited into, rather than only direct mentions: mark the conversation subscribed on first mention, then gate `onMessage` on the flag. This is exactly what `examples/minimal-channel/lib/channel.ts` ships:

```ts title="examples/minimal-channel/lib/channel.ts"
import { createChannel } from "@copilotkit/channels";
import { HttpAgent } from "@ag-ui/client";
import { required } from "@/lib/env";

const channel = createChannel({
  name: "scratch",
  agent: new HttpAgent({ url: required("AGENT_URL") }),
});

channel.onMention(async ({ thread, message }) => {
  await thread.subscribe();
  await thread.runAgent();
});

channel.onMessage(async ({ thread, message }) => {
  if (await thread.isSubscribed()) await thread.runAgent();
});

export { channel };
```

How an inbound message routes through this pair — `onMention` takes priority, and the subscription gate decides whether unmentioned messages reach the agent:

```mermaid
flowchart TD
    E[Inbound platform message] --> M{Mentions the agent?}
    M -- yes --> A["onMention: thread.subscribe() + thread.runAgent()"]
    M -- no --> B[onMessage]
    B --> S{"await thread.isSubscribed()"}
    S -- true --> R["thread.runAgent()"]
    S -- false --> I[Ignore]
```

<Note>
The minimal-channel README suggests a no-model-call verification variant: post a single `🪁` from `onMention` instead of running the agent, confirm the round trip end to end, then swap the body back to `thread.runAgent()`.
</Note>

## Conversation lifecycle handlers

`onThreadStarted` fires when a conversation surface opens — for example, the Slack assistant pane — and receives `{ thread, user, actor }`. `onWelcome` fires when the app is installed or a conversation is activated and additionally receives `platform`. Both are the right place for greeting messages or `thread.setSuggestedPrompts(...)`, and unlike `onMention`/`onMessage` they expose the `user`.

## Commands, interactions, and interrupts

**`onCommand(name, fn)`** receives a `CommandContext` where arguments arrive as **`text`** — the raw string after the command name, not `args`. `options` holds the parsed, typed form and is populated only on surfaces that deliver structured arguments natively (Discord); on text-only surfaces like Slack it is empty. Command arguments are never posted to the channel, so they are absent from reconstructed history — hand them to the agent explicitly:

```ts
channel.onCommand("triage", async ({ thread, text }) => {
  await thread.runAgent({ prompt: `Triage: ${text}` });
});
```

**`onInteraction<T>(id, fn)`** handles a bound action by explicit binding ID and receives an `InteractionContext<T>`. Most interactive UI instead uses inline `onClick`/`onSelect` handlers on `<Button>` and `<Select>`, which are keyed by content-stable IDs — reach for `onInteraction` when you bind actions explicitly rather than inline.

**`onInterrupt<T>(event, fn)`** fires when the agent pauses itself mid-run (a LangGraph-style interrupt during `thread.runAgent()`). The `event` name matches what the agent emits; the handler gets the typed `payload` plus the live thread, collects the human's answer, and re-enters the run with `thread.resume(value)`:

```ts
channel.onInterrupt<{ question: string }>("ask_human", async ({ thread, payload }) => {
  const answer = await thread.awaitChoice<string>(/* UI built from payload.question */);
  await thread.resume(answer); // agent continues from where it paused
});
```

## Reactions and modals

**`onReaction([emoji,] fn)`** fires when an emoji reaction is added or removed and receives a `ReactionEvent`; the optional first argument filters by emoji. A per-message variant also exists — `<Message onReaction={…}>` fires when a user reacts to that specific message.

**`onModalSubmit(callbackId, fn)`** and **`onModalClose(callbackId, fn)`** receive modal outcomes routed by the modal's `callbackId` — not by inline handlers. Return `{ errors }` from a submit handler to keep the modal open with field-level validation errors:

```tsx
channel.onModalSubmit("feedback", async ({ values, thread }) => {
  if (!values.body) return { errors: { body: "Tell us what happened." } };
  await thread?.post(<Section>Thanks — logged it.</Section>);
});
```

<Warning>
`thread` is optional on `ModalSubmitEvent` — a submission may arrive without a conversation context, so keep the `?.`. `onModalClose` fires only when the modal was opened with `notifyOnClose`.
</Warning>

## Handler pitfalls

- Do not use `Bot`-prefixed APIs (`createBot`, `new Bot()`, `channel.on("message", …)`) — they exist nowhere in the shipped packages. Use the named `onMention` / `onMessage` / `onCommand` handlers on the object `createChannel` returns.
- Do not expect `user` in `onMention`/`onMessage` context — those two get `{ thread, message }` only.
- Do not read slash-command arguments from `args` — the field is `text` (raw) or `options` (structured surfaces only).
- Do not return `thread.post(...)` from a concise arrow — handlers must return `void | Promise<void>`.
- Attach all handlers before the runtime starts the Channel; there is no `channel.start()` to sequence around — listener creation starts it.

## Related pages

<CardGroup cols={2}>
  <Card title="Thread API reference" href="/thread-api-reference">
    Every method on the per-conversation thread handle, including runAgent options, subscribe/unsubscribe/isSubscribed, and which methods are capability-gated.
  </Card>
  <Card title="Slash commands and modals" href="/slash-commands-and-modals">
    CommandContext in depth, opening modals with ctx.openModal and Modal({...}), and routing by callbackId.
  </Card>
  <Card title="Human-in-the-loop approvals" href="/human-in-the-loop">
    awaitChoice pickers, onInterrupt + thread.resume, and making approval buttons survive restarts.
  </Card>
  <Card title="Minimal Channel example" href="/minimal-channel-example">
    The complete listener that ships the onMention/onMessage subscribe pattern, file by file.
  </Card>
</CardGroup>
