# Channels SDK Documentation

> Technical documentation for @copilotkit/channels — the SDK that connects any AG-UI-compatible agent to Slack, Microsoft Teams, Discord, Telegram, and WhatsApp with native interactive UI. For developers building, wiring, and operating a long-running Channels listener.

## Context Links

- [Agent index](https://grok-wiki.com/public/docs/copilotkit-channels-sdk-4c947e3e6161/llms.txt)
- [Human interactive docs](https://grok-wiki.com/public/docs/copilotkit-channels-sdk-4c947e3e6161)
- [GitHub repository](https://github.com/CopilotKit/channels-sdk)

## Repository Metadata

- Repository: CopilotKit/channels-sdk

- Generated: 2026-08-05T06:44:14.357Z
- Updated: 2026-08-05T06:44:30.818Z
- Runtime: Claude Code
- Format: Documentation
- Pages: 20

## Page Index

- 01. [Overview](https://grok-wiki.com/public/docs/copilotkit-channels-sdk-4c947e3e6161/pages/01-overview.md) - What the Channels SDK exposes: the five-piece mental model (Channel, Thread, Tools, UI, Context), the you-run vs Intelligence-manages boundary, runtime assumptions (Node.js 22+, long-running process, no serverless), and the shortest path to a first online Channel.
- 02. [Installation](https://grok-wiki.com/public/docs/copilotkit-channels-sdk-4c947e3e6161/pages/02-installation.md) - Install @copilotkit/channels with @copilotkit/runtime as a version-locked pair, enforce ESM with npm pkg set type=module, pin @ag-ui/client via overrides to avoid the duplicate-AbstractAgent compile failure, and configure tsconfig with jsxImportSource for the Channels JSX runtime.
- 03. [Quickstart](https://grok-wiki.com/public/docs/copilotkit-channels-sdk-4c947e3e6161/pages/03-quickstart.md) - Build the first managed Channel: create the Channel in CopilotKit Intelligence, write the createChannel + CopilotRuntime + createCopilotNodeListener listener, set the four environment variables, start with node --env-file, and verify status().overall === "online" before trusting the deploy.
- 04. [Set up with a coding agent](https://grok-wiki.com/public/docs/copilotkit-channels-sdk-4c947e3e6161/pages/04-set-up-with-a-coding-agent.md) - The agent-driven setup path: npx copilotkit@latest channels setup, the hosted channels-guide.md workflow, installing setup-slack-channel via the CLI instead of vendoring it, and how this repository's canonical .agents/skills layout and .claude symlink keep the build-channels-agent skill authoritative.
- 05. [Architecture and the runtime boundary](https://grok-wiki.com/public/docs/copilotkit-channels-sdk-4c947e3e6161/pages/05-architecture-and-the-runtime-boundary.md) - How a turn flows: platform event → Intelligence ingress → your Channels process → agent over AG-UI → native UI back into the conversation. What you host (agent, tools, listener, state) versus what Intelligence manages (platform credentials, ingress, delivery, reconnects), and why a Channel needs a persistent gateway connection.
- 06. [Channel lifecycle and status](https://grok-wiki.com/public/docs/copilotkit-channels-sdk-4c947e3e6161/pages/06-channel-lifecycle-and-status.md) - The runtime owns the lifecycle — there is no channel.start(). Listener creation starts the Channel; channels.ready() settles activation but also resolves on setup_required; the six SDK status values (connecting, online, setup_required, reconnecting, error, stopped) versus the Intelligence dashboard states; teardown ordering for SIGINT/SIGTERM.
- 07. [Managed Channels vs direct adapters](https://grok-wiki.com/public/docs/copilotkit-channels-sdk-4c947e3e6161/pages/07-managed-channels-vs-direct-adapters.md) - The managed default (no adapter, Channel Code from Intelligence, no platform tokens in your process) versus the direct-adapter path (adapters: [slack({ botToken, appToken })], Socket Mode, defaultSlackTools/defaultSlackContext). When each applies, and why switching to a direct adapter to escape setup_required is a known failure mode.
- 08. [Handle mentions, messages, and subscriptions](https://grok-wiki.com/public/docs/copilotkit-channels-sdk-4c947e3e6161/pages/08-handle-mentions-messages-and-subscriptions.md) - 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.
- 09. [Add tools](https://grok-wiki.com/public/docs/copilotkit-channels-sdk-4c947e3e6161/pages/09-add-tools.md) - Define typed agent tools with defineChannelTool and any Standard Schema validator (Zod, Valibot, ArkType): the ChannelToolContext shape ({ thread, message?, user, actor, signal?, platform }), return-value rules (raw data back to the agent, error text on failure), and registration via createChannel({ tools }) or channel.tool().
- 10. [Render interactive UI](https://grok-wiki.com/public/docs/copilotkit-channels-sdk-4c947e3e6161/pages/10-render-interactive-ui.md) - Post one JSX tree that lowers to Block Kit, Adaptive Cards, or Discord components: thread.post/update/delete, inline onClick/onSelect handlers with content-stable IDs, graceful degradation on surfaces that skip unsupported nodes, and agent-rendered components via defineChannelComponent (0.7+).
- 11. [Human-in-the-loop approvals](https://grok-wiki.com/public/docs/copilotkit-channels-sdk-4c947e3e6161/pages/11-human-in-the-loop-approvals.md) - Gate agent actions on humans: thread.awaitChoice<T> to block a tool handler on a typed button choice, onInterrupt + thread.resume for agent-originated pauses (LangGraph-style interrupts), and making approval buttons survive restarts with a durable StateStore adapter plus registered components.
- 12. [Slash commands and modals](https://grok-wiki.com/public/docs/copilotkit-channels-sdk-4c947e3e6161/pages/12-slash-commands-and-modals.md) - Handle commands with channel.onCommand — arguments arrive as raw text (options is populated only on structured surfaces like Discord) — hand them to the agent explicitly, and open modals with ctx.openModal calling Modal({...}) as a function (not <Modal> JSX), routing submissions by callbackId to onModalSubmit/onModalClose.
- 13. [Author a platform adapter](https://grok-wiki.com/public/docs/copilotkit-channels-sdk-4c947e3e6161/pages/13-author-a-platform-adapter.md) - Implement the PlatformAdapter contract for a new surface: ingress via start(sink), egress rendering of ChannelNode[] with a total renderer that skips unsupported nodes, createRunRenderer for live agent streaming, decodeInteraction with content-stable ID recovery, and the declared capabilities object.
- 14. [createChannel reference](https://grok-wiki.com/public/docs/copilotkit-channels-sdk-4c947e3e6161/pages/14-createchannel-reference.md) - Every createChannel option with constraints: required identifyUser ("platform" or a callback), Channel Code naming rules for name, the agent factory contract and per-turn cloning, adapters, tools, context, components, commands, store (adapter, state schema, actionRetentionMs, concurrency), showToolStatus, replyContinuation, and sanitizeAgentEvents.
- 15. [Thread API reference](https://grok-wiki.com/public/docs/copilotkit-channels-sdk-4c947e3e6161/pages/15-thread-api-reference.md) - The per-conversation thread handle: post, update, delete, stream, postFile, postEphemeral, runAgent (prompt, context, tools, transcript, memory grants), resume, awaitChoice, subscribe/unsubscribe/isSubscribed, getMessages, setTitle, setSuggestedPrompts, react, state/setState, and lookupUser — including which methods are capability-gated and degrade instead of throwing.
- 16. [UI components reference](https://grok-wiki.com/public/docs/copilotkit-channels-sdk-4c947e3e6161/pages/16-ui-components-reference.md) - The full channels-ui JSX vocabulary with props and degradation rules: layout components (Message, Header, Section, Markdown, Fields, Field, Context, Divider, Image, Table, Chart), interactive components (Actions, Button, Select, Input), modal components (Modal, TextInput, ModalSelect, RadioButtons), and handler context shapes.
- 17. [Configuration reference](https://grok-wiki.com/public/docs/copilotkit-channels-sdk-4c947e3e6161/pages/17-configuration-reference.md) - Environment variables and project configuration: INTELLIGENCE_API_KEY, CHANNEL_CODE, PORT, the paired INTELLIGENCE_API_URL / INTELLIGENCE_GATEWAY_WS_URL overrides (bare base URLs, never derived from each other), AGENT_URL for remote AG-UI agents, plus the required tsconfig (jsxImportSource, module settings) and package.json shape (type: module, overrides pin).
- 18. [Minimal Channel example](https://grok-wiki.com/public/docs/copilotkit-channels-sdk-4c947e3e6161/pages/18-minimal-channel-example.md) - The smallest complete listener, file by file: lib/channel.ts (createChannel plus onMention/onMessage with the subscribe pattern), lib/runtime.ts (CopilotRuntime with an explicit CopilotKitIntelligence connection), lib/env.ts (fail-fast env loading), and server.ts (ready() gate, HTTP server, SIGINT teardown) — with the 🪁 no-model-call verification trick.
- 19. [OpenTag reference application](https://grok-wiki.com/public/docs/copilotkit-channels-sdk-4c947e3e6161/pages/19-opentag-reference-application.md) - The flagship Channels app vendored as a pinned git submodule: what it demonstrates (Python LangGraph agent over AG-UI, Slack and Teams surfaces, file-aware prompts, approval-gated Linear/Notion writes), how to fetch it with git submodule update --init, its prerequisites, and the deliberate one-line workflow for bumping the pin.
- 20. [Troubleshooting](https://grok-wiki.com/public/docs/copilotkit-channels-sdk-4c947e3e6161/pages/20-troubleshooting.md) - Documented failure modes with fixes: the duplicate @ag-ui/client "_debug private property" compile error, TS1309 from missing type: module, TS2345 ModalView narrowing, JSX compiled against React, setup_required and Waiting for runtime states, ready() resolving on a degraded Channel, Unknown option '--skill' from a stale CLI, and the invented Bot-prefixed APIs that never existed.

## Source File Index

- `.agents/skills/build-channels-agent/evals/evals.json`
- `.agents/skills/build-channels-agent/references/adapter-authoring.md`
- `.agents/skills/build-channels-agent/references/hitl-patterns.md`
- `.agents/skills/build-channels-agent/references/ui-components.md`
- `.agents/skills/build-channels-agent/SKILL.md`
- `.gitmodules`
- `AGENTS.md`
- `examples/minimal-channel/.env.example`
- `examples/minimal-channel/lib/channel.ts`
- `examples/minimal-channel/lib/env.ts`
- `examples/minimal-channel/lib/runtime.ts`
- `examples/minimal-channel/package.json`
- `examples/minimal-channel/README.md`
- `examples/minimal-channel/server.ts`
- `examples/minimal-channel/tsconfig.json`
- `examples/README.md`
- `README.md`

---

## 01. Overview

> What the Channels SDK exposes: the five-piece mental model (Channel, Thread, Tools, UI, Context), the you-run vs Intelligence-manages boundary, runtime assumptions (Node.js 22+, long-running process, no serverless), and the shortest path to a first online Channel.

- Page Markdown: https://grok-wiki.com/public/docs/copilotkit-channels-sdk-4c947e3e6161/pages/01-overview.md
- Generated: 2026-08-05T06:37:48.127Z

### Source Files

- `README.md`
- `.agents/skills/build-channels-agent/SKILL.md`
- `examples/minimal-channel/package.json`
- `AGENTS.md`

---
title: "Overview"
description: "What the Channels SDK exposes: the five-piece mental model (Channel, Thread, Tools, UI, Context), the you-run vs Intelligence-manages boundary, runtime assumptions (Node.js 22+, long-running process, no serverless), and the shortest path to a first online Channel."
---

The Channels SDK (`@copilotkit/channels`, MIT-licensed) connects one AG-UI-compatible agent to Slack, Microsoft Teams, Discord, Telegram, and WhatsApp with native interactive UI. You write handlers, tools, and JSX-rendered messages once; per-platform adapters translate the same message tree into Slack Block Kit, Teams Adaptive Cards, or Discord components. The agent keeps its own model, tools, and business logic — the built-in agent, LangGraph, CrewAI, Mastra, Pydantic AI, Google ADK, or any custom AG-UI agent all attach through the same `agent` factory. This repository carries the docs entry point, the `build-channels-agent` skill (the API authority under `.agents/skills/`), and runnable examples; the SDK implementation itself lives in `CopilotKit/CopilotKit` under `packages/channels`.

## The five-piece mental model

Every Channels program is built from the same five pieces:

| Piece | What it is | Primary API |
| --- | --- | --- |
| **Channel** | The unit you configure and attach handlers to | `createChannel()` returns a `Channel` |
| **Thread** | The per-conversation handle passed to every handler; you render and drive the conversation through it | `thread.post`, `thread.runAgent`, `thread.awaitChoice`, `thread.subscribe`, … |
| **Tools** | Typed functions the agent can call, validated by any Standard Schema library (Zod, Valibot, ArkType) | `defineChannelTool` |
| **UI** | JSX from `@copilotkit/channels` that renders natively per platform and degrades gracefully where a node is unsupported | `<Message>`, `<Section>`, `<Actions>`, `<Button>`, … |
| **Context** | `{ description, value }` entries injected into the agent's prompt per run | `createChannel({ context })` or `thread.runAgent({ context })` |

<Warning>
The API is `createChannel`, `defineChannelTool`, `defineChannelCommand`, and `ChannelToolContext`. An earlier pre-release used `Bot`-prefixed names (`createBot`, `defineBotTool`, `BotToolContext`); they exist nowhere in the shipped packages and importing them fails to compile.
</Warning>

## The boundary: you run vs Intelligence manages

Your agent and application logic run in your infrastructure. CopilotKit Intelligence manages the platform connection and delivers each turn to your long-running Channels process. A CopilotKit Intelligence API key is required (free tier available); there is no standalone way to run a Channel.

```mermaid
flowchart LR
    subgraph platforms["Messaging platforms"]
        slack["Slack / Teams / Discord"]
    end
    subgraph intelligence["CopilotKit Intelligence manages"]
        ingress["Platform credentials & ingress"]
        delivery["Credentialed delivery & reconnects"]
        registry["Runtime registration & health"]
    end
    subgraph yours["You run (long-running Node 22+ process)"]
        listener["createCopilotNodeListener"]
        runtime["CopilotRuntime + Channel"]
        agent["AG-UI agent + tools + state"]
    end
    slack -->|platform event| ingress
    ingress -->|gateway socket| listener
    listener --> runtime
    runtime -->|AG-UI| agent
    runtime -->|rendered JSX| delivery
    delivery -->|native platform UI| slack
```

| You run | CopilotKit Intelligence manages |
| --- | --- |
| Your agent, model credentials, tools, and business logic | Slack and Microsoft Teams platform credentials |
| The long-running Channels listener | Platform ingress and credentialed delivery |
| Application state, deployment, and logs | Runtime registration, health, and reconnects |

In the default **managed Channel** path there is no adapter in your code and no platform token in your process — Intelligence holds the credentials and you add platforms in its dashboard. Passing `adapters: [slack({ botToken, appToken })]` is the secondary, direct path for when you own the platform connection yourself.

## Runtime assumptions

- **Node.js 22 or later.** The launcher needs the global `WebSocket`.
- **A long-running process or container.** A Channel owns a persistent gateway connection to Intelligence; a serverless request handler cannot host one.
- **ESM.** The startup path uses top-level `await`, so the project needs `"type": "module"` (`npm pkg set type=module`).
- **No `channel.start()`.** The runtime owns the lifecycle: attach the Channel to a `CopilotRuntime` and create a listener with `createCopilotNodeListener` — creating the listener is what starts the Channel.
- **Paired packages.** `@copilotkit/channels` and `@copilotkit/runtime` ship as a tested pair; upgrade them together.

## Shortest path to a first online Channel

<Steps>
<Step title="Create a managed Channel in CopilotKit Intelligence">
Create a Channel and connect Slack in the Intelligence dashboard. Keep the Channel **Code** and the project-scoped API key — `createChannel({ name })` must equal that Channel Code exactly.
</Step>
<Step title="Install the SDK pair">

```sh
npm install @copilotkit/channels @copilotkit/runtime
npm install --save-dev tsx typescript @types/node
npm pkg set type=module
```

</Step>
<Step title="Write the listener">
Create the Channel, attach a handler, and start it through the runtime:

```ts
import { createChannel } from "@copilotkit/channels";
import { CopilotRuntime, CopilotKitIntelligence } from "@copilotkit/runtime/v2";
import { createCopilotNodeListener } from "@copilotkit/runtime/v2/node";

const channel = createChannel({
  name: process.env.CHANNEL_CODE!,
  identifyUser: "platform",
  agent: makeAgent, // (threadId) => any AG-UI agent
});

channel.onMessage(async ({ thread }) => {
  await thread.runAgent();
});

const runtime = new CopilotRuntime({
  agents: {},
  intelligence: new CopilotKitIntelligence({
    apiKey: process.env.INTELLIGENCE_API_KEY!,
  }),
  channels: [channel],
});

const listener = createCopilotNodeListener({ runtime, basePath: "/api/copilotkit" });
```

</Step>
<Step title="Start and verify">

```sh
node --env-file=.env --import tsx channel.ts
```

Gate the deploy on real status — `ready()` also resolves on `setup_required`:

```ts
await listener.channels.ready({ timeoutMs: 30_000 });
if (listener.channels.status().overall !== "online") {
  throw new Error("Channel is not online");
}
```

When Intelligence reports **Online**, invite the app to a Slack channel and mention it — the agent receives the conversation and replies in the thread.
</Step>
</Steps>

<Tip>
The fastest path is agent-driven: `npx copilotkit@latest channels setup` installs a pointer skill that fetches the current workflow from `copilotkit.ai/channels-guide.md` and walks your coding agent through the project, the managed Channel, the provider app, and the runtime.
</Tip>

## What this repository contains

:::files
```
channels-sdk/
├── README.md                          # Product overview + hand-built quickstart
├── AGENTS.md                          # Which skill owns which task
├── .agents/skills/
│   └── build-channels-agent/SKILL.md  # Canonical API authority (mirrored via .claude symlink)
└── examples/
    ├── minimal-channel/               # Smallest complete listener (4 files)
    └── OpenTag/                       # Flagship app, pinned git submodule
```
:::

The `minimal-channel` example splits the listener into `lib/channel.ts` (Channel + handlers), `lib/runtime.ts` (runtime and Intelligence wiring), `lib/env.ts` (fail-fast env loading), and `server.ts` (`ready()` gate, HTTP server, `SIGINT` teardown). OpenTag is a complete production-shaped application — a Python LangGraph agent over AG-UI with Slack and Teams surfaces — fetched with `git submodule update --init examples/OpenTag`.

## Next

<CardGroup cols={2}>
  <Card title="Installation" href="/installation">
    Install the version-locked package pair, enforce ESM, and pin @ag-ui/client to avoid the duplicate-AbstractAgent compile failure.
  </Card>
  <Card title="Quickstart" href="/quickstart">
    Build the first managed Channel end to end and verify status().overall === "online".
  </Card>
  <Card title="Architecture and the runtime boundary" href="/architecture">
    How a turn flows from platform event through Intelligence to your process and back.
  </Card>
  <Card title="Set up with a coding agent" href="/coding-agent-setup">
    The npx copilotkit@latest channels setup path and the hosted channels-guide.md workflow.
  </Card>
  <Card title="Minimal Channel example" href="/minimal-channel-example">
    The smallest complete listener, file by file, with the no-model-call verification trick.
  </Card>
  <Card title="Channel lifecycle and status" href="/channel-lifecycle">
    The six SDK status values, why ready() is not proof of life, and teardown ordering.
  </Card>
</CardGroup>

---

## 02. Installation

> Install @copilotkit/channels with @copilotkit/runtime as a version-locked pair, enforce ESM with npm pkg set type=module, pin @ag-ui/client via overrides to avoid the duplicate-AbstractAgent compile failure, and configure tsconfig with jsxImportSource for the Channels JSX runtime.

- Page Markdown: https://grok-wiki.com/public/docs/copilotkit-channels-sdk-4c947e3e6161/pages/02-installation.md
- Generated: 2026-08-05T06:37:36.410Z

### Source Files

- `.agents/skills/build-channels-agent/SKILL.md`
- `README.md`
- `examples/minimal-channel/package.json`
- `examples/minimal-channel/tsconfig.json`

---
title: "Installation"
description: "Install @copilotkit/channels with @copilotkit/runtime as a version-locked pair, enforce ESM with npm pkg set type=module, pin @ag-ui/client via overrides to avoid the duplicate-AbstractAgent compile failure, and configure tsconfig with jsxImportSource for the Channels JSX runtime."
---

A working Channels project requires four things installed and configured before any code runs: `@copilotkit/channels` and `@copilotkit/runtime` at matching versions, ESM enabled in `package.json`, a single deduplicated copy of `@ag-ui/client`, and a tsconfig that points the JSX factory at `@copilotkit/channels` instead of React. Each of these has a specific, documented failure mode when skipped, so the install order below is not optional polish — it is the difference between a project that typechecks and one that fails with misleading errors.

## Prerequisites

- **Node.js 22 or later.** The launcher requires the global `WebSocket` implementation that ships with Node 22+.
- **A long-running Node process or container.** A Channel owns a persistent gateway connection to CopilotKit Intelligence; a serverless request handler cannot host one.
- **A CopilotKit Intelligence API key** (free tier available). There is no standalone or DIY way to run a Channel.

## Install the package pair

`@copilotkit/channels` is batteries-included: one install provides the engine, the JSX vocabulary, the UI primitives, the testing API, and every platform adapter (on subpaths such as `@copilotkit/channels/slack`, `/teams`, `/discord`, `/telegram`, `/whatsapp`). `@copilotkit/runtime` is required to *start* a Channel — the runtime owns the Channel lifecycle.

```sh
npm install @copilotkit/channels @copilotkit/runtime
npm install --save-dev tsx typescript @types/node
npm pkg set type=module
```

<Warning>
Channels and Runtime ship and are tested together as a pair. Always upgrade both packages in the same change. Known-good pairs verified against the SDK's own compiled samples:

| `@copilotkit/channels` | `@copilotkit/runtime` | Notes |
| --- | --- | --- |
| `0.7.1` | `1.66.1` | Adds `defineChannelComponent` and native-node helpers (0.7+ only) |
| `0.6.1` | `1.65.0` | The pair pinned by the hosted Slack guide |
</Warning>

To lock a known-good pair exactly:

```sh
npm install --save-exact @copilotkit/channels@0.6.1 @copilotkit/runtime@1.65.0
```

Standalone `@copilotkit/channels-ui`, `-slack`, `-teams` (and similar) packages exist and work, but the single umbrella dependency is the documented path. Do not import from `@copilotkit/channels-ui` unless it is a direct dependency — as a transitive dep it resolves under npm's hoisted layout but fails under pnpm's isolated one.

## Enforce ESM

The standard listener startup uses top-level `await` (`await channels.ready(...)`), so the project must be an ES module:

```sh
npm pkg set type=module
```

Skipping this produces the compile error:

```text
TS1309: The current file is a CommonJS module whose imports will produce 'require' calls
```

The `examples/minimal-channel` project in this repository ships with `"type": "module"` already set in its `package.json`.

## Pin @ag-ui/client to one copy

**Dedupe `@ag-ui/client` or the project will not compile.** Channels and Runtime both depend on one exact version, but a transitive dependency (`@ag-ui/mcp-middleware`) pulls an older one, and npm nests it. Two copies means two separate `AbstractAgent` type declarations, so passing *any* agent to `createChannel({ agent })` fails with a confusing error about "separate declarations of a private property `_debug`". This is the single most common reason a correct-looking Channel refuses to typecheck.

First find the version Runtime declares:

```sh
npm ls @ag-ui/client
```

Then pin that version in `package.json` and reinstall:

<Tabs>
<Tab title="npm">

```json
{
  "overrides": {
    "@ag-ui/client": "0.0.57"
  }
}
```

</Tab>
<Tab title="pnpm">

```json
{
  "pnpm": {
    "overrides": {
      "@ag-ui/client": "0.0.57"
    }
  }
}
```

</Tab>
<Tab title="yarn">

```json
{
  "resolutions": {
    "@ag-ui/client": "0.0.57"
  }
}
```

</Tab>
</Tabs>

Use the version your installed `@copilotkit/runtime` declares — `0.0.57` is the version matching the pairs above. The `examples/minimal-channel` project takes the equivalent route of declaring `"@ag-ui/client": "^0.0.57"` as a direct dependency under pnpm.

## Configure tsconfig

Any file that contains Channels JSX must have the `.tsx` extension, and the tsconfig must point the JSX factory at Channels — this is not React. Without `jsxImportSource`, the JSX tree compiles against React's types and fails.

```json title="tsconfig.json"
{
  "compilerOptions": {
    "target": "ES2022",
    "jsx": "react-jsx",
    "jsxImportSource": "@copilotkit/channels",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "strict": true,
    "types": ["node"]
  }
}
```

Point `jsxImportSource` at `@copilotkit/channels` — the package you installed — not at `@copilotkit/channels-ui`, which is only a transitive dependency of the umbrella package.

<Note>
A listener that renders no JSX can omit `jsx` / `jsxImportSource` entirely. The `examples/minimal-channel` project posts plain text only, so its tsconfig uses `"module": "ESNext"` with `"moduleResolution": "Bundler"` and no JSX settings. Add the JSX options the moment you introduce a `.tsx` file.
</Note>

## Verify the install

<Steps>
<Step title="Confirm a single @ag-ui/client">

```sh
npm ls @ag-ui/client
```

Exactly one version should appear in the tree (deduped entries pointing at the same version are fine). Two distinct versions mean the overrides pin is missing or the lockfile predates it — reinstall after adding the pin.

</Step>
<Step title="Confirm ESM is set">

```sh
npm pkg get type
```

Expected output: `"module"`.

</Step>
<Step title="Typecheck">

```sh
npx tsc --noEmit
```

A clean pass confirms the pair versions match, `@ag-ui/client` is deduplicated, and the JSX configuration resolves. The `_debug` private-property error at this stage always means duplicate `@ag-ui/client` copies; `TS1309` always means missing `type: module`.

</Step>
</Steps>

Once TypeScript is clean, the standard way to run a listener during development is:

```sh
node --env-file=.env --import tsx channel.ts
```

## Common install failures

| Symptom | Cause | Fix |
| --- | --- | --- |
| `separate declarations of a private property '_debug'` | Two nested copies of `@ag-ui/client` | Add the `overrides` pin, reinstall |
| `TS1309: The current file is a CommonJS module` | Missing `"type": "module"` | `npm pkg set type=module` |
| JSX props rejected / React types in errors | Missing `jsxImportSource` | Set `"jsxImportSource": "@copilotkit/channels"` |
| Import from `@copilotkit/channels-ui` fails under pnpm | Transitive dep not hoisted | Import from `@copilotkit/channels` root instead |
| APIs like `createBot` / `defineBotTool` not found | Pre-release names that were never shipped | Use `createChannel`, `defineChannelTool` |

## Next

<CardGroup cols={2}>
<Card title="Quickstart" href="/quickstart">
Create the Channel in Intelligence, write the listener, set the four environment variables, and verify `status().overall === "online"`.
</Card>
<Card title="Set up with a coding agent" href="/coding-agent-setup">
Let `npx copilotkit@latest channels setup` and the hosted guide drive the install and provider configuration for you.
</Card>
<Card title="Configuration reference" href="/configuration-reference">
Every environment variable plus the full required `tsconfig` and `package.json` shape.
</Card>
<Card title="Troubleshooting" href="/troubleshooting">
Full write-ups of the duplicate `@ag-ui/client` error, TS1309, and other documented failure modes.
</Card>
</CardGroup>

---

## 03. Quickstart

> Build the first managed Channel: create the Channel in CopilotKit Intelligence, write the createChannel + CopilotRuntime + createCopilotNodeListener listener, set the four environment variables, start with node --env-file, and verify status().overall === "online" before trusting the deploy.

- Page Markdown: https://grok-wiki.com/public/docs/copilotkit-channels-sdk-4c947e3e6161/pages/03-quickstart.md
- Generated: 2026-08-05T06:37:42.435Z

### Source Files

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

---
title: "Quickstart"
description: "Build the first managed Channel: create the Channel in CopilotKit Intelligence, write the createChannel + CopilotRuntime + createCopilotNodeListener listener, set the four environment variables, start with node --env-file, and verify status().overall === \"online\" before trusting the deploy."
---

A managed Channel is a long-running Node.js process that registers itself with CopilotKit Intelligence and receives every platform turn over a persistent gateway connection. The process you write does three things: declare the Channel with `createChannel`, attach it to a `CopilotRuntime` connected to `CopilotKitIntelligence`, and start it by creating a listener with `createCopilotNodeListener` — there is no `channel.start()`. Intelligence holds the Slack or Microsoft Teams credentials; your process carries only an Intelligence API key and the Channel Code.

<Note>
Prefer an agent-driven setup? `npx copilotkit@latest channels setup` walks a coding agent through this same path plus the provider-console steps. See <a href="/coding-agent-setup">Set up with a coding agent</a>. The steps below are the manual equivalent.
</Note>

## Prerequisites

- Node.js 22 or later (the launcher requires global `WebSocket`).
- A long-running Node process or container. A serverless request handler cannot host a Channel — it must own a persistent gateway connection.
- A CopilotKit Intelligence account. The managed path requires an Intelligence API key; there is no standalone way to run a Channel.
- An ESM project (`"type": "module"` in `package.json`) — the listener code uses top-level `await`.

## Build the Channel

<Steps>
<Step title="Create the Channel in CopilotKit Intelligence">

Create a Channel in the [CopilotKit Intelligence dashboard](https://docs.copilotkit.ai/channels) and connect Slack. Keep two values for later steps:

- The **Channel Code** — the identifier you pass as `createChannel({ name })`. It must match exactly: 3–64 characters, starting with a lowercase letter, lowercase letters and digits separated by single hyphens, unique within the project, and never the literal `channels`. A mismatch leaves the Channel at **Waiting for runtime** in the dashboard.
- The **project-scoped Intelligence API key**, issued from **API Keys** in the Intelligence project sidebar.

</Step>
<Step title="Install the SDK">

```sh
npm install @copilotkit/channels @copilotkit/runtime
npm install --save-dev tsx typescript @types/node
npm pkg set type=module
```

`@copilotkit/channels` and `@copilotkit/runtime` ship as a tested pair — always upgrade them together. If the project later fails to compile with an error about separate declarations of a private `_debug` property, pin `@ag-ui/client` with an `overrides` entry; see <a href="/installation">Installation</a>.

</Step>
<Step title="Write the listener">

The listener below uses CopilotKit's built-in agent. Replace `makeAgent` with any AG-UI-compatible agent factory (LangGraph, CrewAI, Mastra, a remote `HttpAgent`, …) without changing the Channel lifecycle.

```ts channel.ts
import { createServer } from "node:http";
import { createChannel } from "@copilotkit/channels";
import {
  BuiltInAgent,
  CopilotKitIntelligence,
  CopilotRuntime,
} from "@copilotkit/runtime/v2";
import { createCopilotNodeListener } from "@copilotkit/runtime/v2/node";

function required(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`Missing required environment variable: ${name}`);
  return value;
}

function makeAgent(threadId: string) {
  const agent = new BuiltInAgent({ model: "openai:gpt-5.4-mini" });
  agent.threadId = threadId;
  return agent;
}

const channel = createChannel({
  name: required("CHANNEL_CODE"),
  identifyUser: "platform",
  agent: makeAgent,
});

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 }],
  });
});

const intelligence = new CopilotKitIntelligence({
  apiKey: required("INTELLIGENCE_API_KEY"),
});

const runtime = new CopilotRuntime({
  agents: {},
  intelligence,
  identifyUser: () => ({
    id: "channels-runtime",
    name: "Channels Runtime",
  }),
  channels: [channel],
});

const listener = createCopilotNodeListener({
  runtime,
  basePath: "/api/copilotkit",
});

const channels = listener.channels;
if (!channels) throw new Error("Channels control surface was not created.");

const server = createServer(listener);
const shutdown = async () => {
  await channels.stop();
  if (server.listening) server.close();
};
process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown);

await channels.ready({ timeoutMs: 30_000 });

const status = channels.status();
if (status.overall !== "online") {
  throw new Error(`Channel is not online: ${JSON.stringify(status)}`);
}

const port = Number(process.env.PORT ?? 3000);
server.listen(port, () => {
  console.log(`Channel online; lifecycle server listening on :${port}`);
});
```

Key points in this file:

- `identifyUser: "platform"` on `createChannel` is required. It derives the canonical user from provider + workspace + platform user id.
- `agent` is a factory `(threadId) => agent` — return a fresh agent per thread rather than sharing one stateful instance.
- Creating the listener is what starts the Channel, so `SIGINT`/`SIGTERM` teardown is wired around it and `channels.stop()` runs before the HTTP server closes.
- Managed delivery arrives over the Channel's own gateway socket, not the HTTP port — but keep the server: the runtime serves web requests through it, and most hosts require a listening port for health checks.

</Step>
<Step title="Set the four environment variables">

```dotenv .env
OPENAI_API_KEY=<openai-api-key>
INTELLIGENCE_API_KEY=<project-api-key>
CHANNEL_CODE=<channel-code-from-intelligence>
PORT=3000
```

<ParamField body="OPENAI_API_KEY" type="string" required>
Model credential consumed by `BuiltInAgent`. Not needed when you swap in a remote AG-UI agent instead.
</ParamField>

<ParamField body="INTELLIGENCE_API_KEY" type="string" required>
The project-scoped key from the Intelligence **API Keys** sidebar. Keep `.env` out of source control and never expose this key in browser code.
</ParamField>

<ParamField body="CHANNEL_CODE" type="string" required>
The exact Channel Code from Intelligence. Validated by the runtime at startup, not by `createChannel` — a typo fails at start, and the dashboard shows **Waiting for runtime**.
</ParamField>

<ParamField body="PORT" type="number" default="3000">
Port for the lifecycle HTTP server.
</ParamField>

Hosted Intelligence supplies the API and gateway endpoints automatically. Self-hosted deployments override `INTELLIGENCE_API_URL` and `INTELLIGENCE_GATEWAY_WS_URL` as a pair of bare base URLs — never derive one from the other. See <a href="/configuration-reference">Configuration reference</a>.

</Step>
<Step title="Start it">

```sh
node --env-file=.env --import tsx channel.ts
```

Expected output once activation completes:

```
Channel online; lifecycle server listening on :3000
```

When the Intelligence dashboard reports **Online**, invite the app to a Slack channel and mention it. The agent receives the conversation and replies in the thread.

</Step>
</Steps>

## Verify before trusting the deploy

`await channels.ready()` is not proof of life. It settles when activation resolves — including into `setup_required`, because a declared-but-unprovisioned Channel is a valid degraded state. Without the status gate you get a process that starts cleanly, serves HTTP 200 on its port, and answers nothing in Slack.

```ts
await channels.ready({ timeoutMs: 30_000 });

const status = channels.status();
if (status.overall !== "online") {
  throw new Error(`Channel is not online: ${JSON.stringify(status)}`);
}
```

`channels.status()` returns `overall` plus a per-Channel map, using the SDK's six lifecycle values:

| `status().overall` | Meaning |
| --- | --- |
| `connecting` | Activation in flight; `ready()` has not settled. |
| `online` | Connected. Send a real provider message to verify the full path. |
| `setup_required` | Declared but unprovisioned — finish the provider setup in Intelligence. `ready()` resolves here. |
| `reconnecting` | The gateway socket dropped; the connection layer is retrying. |
| `error` | Activation failed. `ready()` rejects with the cause. |
| `stopped` | `channels.stop()` was called. |

Activation errors are recorded as status and surfaced through `ready()` / `status()` — they are not thrown mid-run. Only an up-front misconfiguration, such as a duplicate or missing Channel name, throws synchronously. These SDK values are a different vocabulary from the Intelligence dashboard states (Waiting for runtime, Setup incomplete, Online, …); see <a href="/channel-lifecycle">Channel lifecycle and status</a> for the mapping.

## Troubleshooting

- **Dashboard stuck at "Waiting for runtime"** — the `name` passed to `createChannel` does not exactly match the Channel Code in Intelligence, or the process never started. Fix the code, not the platform connection.
- **`status().overall === "setup_required"`** — the Channel is declared but the provider setup in Intelligence is unfinished. Complete it in the dashboard. Do not switch to a direct adapter to escape this state — that is a known failure mode, not a fix. See <a href="/managed-vs-direct">Managed Channels vs direct adapters</a>.
- **`TS1309: The current file is a CommonJS module`** — the project is missing `"type": "module"`; run `npm pkg set type=module`. The listener uses top-level `await` and requires ESM.
- **`createChannel({ agent })` fails on a private `_debug` property** — two copies of `@ag-ui/client` are installed. Pin one with an `overrides` entry per <a href="/installation">Installation</a>.

More failure modes with fixes are collected in <a href="/troubleshooting">Troubleshooting</a>.

## Next

<CardGroup cols={2}>
  <Card title="Minimal Channel example" href="/minimal-channel-example">
    The same listener split into files — channel, runtime, env, server — with a no-model-call verification trick.
  </Card>
  <Card title="Handle mentions, messages, and subscriptions" href="/handle-conversations">
    Wire the full set of channel handlers and the subscribe pattern for answering every message in an invited conversation.
  </Card>
  <Card title="Channel lifecycle and status" href="/channel-lifecycle">
    How listener creation starts the Channel, what ready() actually settles on, and teardown ordering for SIGINT/SIGTERM.
  </Card>
  <Card title="Configuration reference" href="/configuration-reference">
    Every environment variable, the paired Intelligence URL overrides, and the required tsconfig and package.json shape.
  </Card>
</CardGroup>

---

## 04. Set up with a coding agent

> The agent-driven setup path: npx copilotkit@latest channels setup, the hosted channels-guide.md workflow, installing setup-slack-channel via the CLI instead of vendoring it, and how this repository's canonical .agents/skills layout and .claude symlink keep the build-channels-agent skill authoritative.

- Page Markdown: https://grok-wiki.com/public/docs/copilotkit-channels-sdk-4c947e3e6161/pages/04-set-up-with-a-coding-agent.md
- Generated: 2026-08-05T06:37:38.449Z

### Source Files

- `README.md`
- `AGENTS.md`
- `.agents/skills/build-channels-agent/SKILL.md`
- `.agents/skills/build-channels-agent/evals/evals.json`

---
title: "Set up with a coding agent"
description: "The agent-driven setup path: npx copilotkit@latest channels setup, the hosted channels-guide.md workflow, installing setup-slack-channel via the CLI instead of vendoring it, and how this repository's canonical .agents/skills layout and .claude symlink keep the build-channels-agent skill authoritative."
---

`npx copilotkit@latest channels setup` is the fastest path to a working Channel: it installs the `channels-setup` skill, prints a prompt, and copies that prompt to your clipboard for you to paste into your coding agent. The installed skill is a pointer, not a workflow — it fetches the actual steps from `https://copilotkit.ai/channels-guide.md` when the agent needs them, so the instructions stay current even if the skill on disk is months old. Building a Channels agent spans a project, an agent, a managed Channel, a provider app, and a long-running runtime; the hosted guide walks the agent through all of it.

## Run the guided setup

<Steps>
<Step title="Install the pointer skill">

```sh
npx copilotkit@latest channels setup
```

Keep the `@latest`. A bare `copilotkit` resolves to whatever is already on PATH or in the npx cache, and an older CLI fails with `Unknown option '--skill'` on related subcommands.

</Step>
<Step title="Paste the prompt into your coding agent">

The command copies the prompt to your clipboard. Its content is:

```text
Read https://copilotkit.ai/channels-guide.md and help the user build their first channel
```

</Step>
<Step title="Answer the guide's two questions">

The hosted guide asks which platform you want — Slack or Microsoft Teams — and which agent framework. It then covers the same setup as the manual quickstart, plus the provider-console and verification steps.

</Step>
<Step title="Let the agent drive the consoles">

Your agent operates the Slack and Intelligence consoles itself, in your own signed-in browser session. If the agent has no browser or computer-use tool yet, it asks you to add one first — that is the intended path, not a fallback. You type the secrets; the agent does the clicking.

</Step>
</Steps>

<Note>
Microsoft Teams setup, and any setup question the Slack skill does not cover, goes through this hosted guide. It is fetched on demand, so prefer it over a remembered sequence of steps.
</Note>

## Install the Slack setup skill on disk

To skip the hosted guide and put the Slack workflow directly into the coding agent you are already running in:

```sh
npx copilotkit@latest skills install --skill setup-slack-channel -y
```

<ParamField body="--skill setup-slack-channel" type="string">
Installs that one skill. `setup-slack-channel` covers the provider half of Slack setup: creating the Slack app, the managed Intelligence Channel, the local runtime, and diagnosing a Channel stuck at `setup_required` or a mention that gets no reply.
</ParamField>

<ParamField body="-y" type="flag">
Installs the named skill without opening an interactive picker.
</ParamField>

Omit `--skill` to install every CopilotKit skill:

```sh
npx copilotkit@latest skills install
```

The skill is scoped to Slack — for Microsoft Teams, use the hosted guide above.

### What the CLI covers versus the browser

The CLI handles the Intelligence side; the provider side stays in the browser, in your session.

| Surface | Operations |
| --- | --- |
| CLI | `copilotkit channels add --adapter slack` declares the Channel and attaches the adapter; `copilotkit channels status` compares your configuration, your code, and the server |
| Browser | Creating the Slack app, installing it into a workspace, issuing the project API key |

No CLI flag accepts a credential value, so the bot token and signing secret stay in your `.env` and with you.

## Why setup-slack-channel is installed, not vendored

`setup-slack-channel` lives in `CopilotKit/CopilotKit` and is delivered by the CLI. This repository does not carry a copy, and `AGENTS.md` instructs agents never to add one.

The rule comes from a concrete failure. This repository previously vendored the skill under `.agents/skills/setup-slack-channel/`. That copy fell roughly 16 KB behind its upstream across all eight files and ended up asserting the opposite of the truth about Slack interactivity — that enabling it does not make buttons work, when disabling it is what breaks human-in-the-loop. Nothing enforced the match, and `skills install` writes into that same path, so following the README overwrote eight tracked files. One home, fetched on demand, is why that cannot recur.

`.gitignore` now enforces the boundary — paths that `skills install` writes are untracked:

```gitignore
# Skills fetched from the CopilotKit registry, not owned here (see AGENTS.md).
/.agents/skills/setup-slack-channel/
/agent/
/skills-lock.json
```

## The canonical skill layout

The repository ships exactly one skill it owns: `build-channels-agent`. `AGENTS.md` designates it the authority on the Channels SDK API — the API is recent and easy to get wrong from memory, so agents are told to ground code in the skill rather than in recalled patterns.

:::files
repo/
├── AGENTS.md                          # skill ownership rules for agents
├── .agents/
│   └── skills/
│       └── build-channels-agent/      # canonical home — edit here
│           ├── SKILL.md               # the API authority
│           ├── references/
│           │   ├── adapter-authoring.md
│           │   ├── hitl-patterns.md
│           │   └── ui-components.md
│           └── evals/
│               └── evals.json         # 8 evals guarding the API surface
└── .claude/
    └── skills/
        └── build-channels-agent -> ../../.agents/skills/build-channels-agent
:::

`.claude/skills/` mirrors the canonical layout for Claude Code, which discovers skills there. `.claude/skills/build-channels-agent` is a symlink to the canonical copy under `.agents/skills/` — edit the canonical file, never the link, so the two cannot drift apart.

### What the skill guarantees

`build-channels-agent/SKILL.md` states that every code sample in it compiles: the samples were transcribed into one project and typechecked under `strict` against both `@copilotkit/channels@0.7.1` + `@copilotkit/runtime@1.66.1` and the `0.6.1` + `1.65.0` pair, with zero errors on both. The `evals/evals.json` file defines eight scenario evals that pin the correct API surface — for example, that generated code uses `createChannel`/`defineChannelTool` rather than the `Bot`-prefixed names that exist nowhere in the shipped packages, defaults to the managed path when the developer does not hold Slack tokens, and gates startup on `status().overall === "online"` instead of trusting `ready()` alone.

### Division of labor between the two skills

| Skill | Home | Covers |
| --- | --- | --- |
| `build-channels-agent` | This repository, `.agents/skills/` | Writing the code: `createChannel`, handlers, tools, slash commands, JSX message UI, modals, human-in-the-loop, the runtime wiring that starts a Channel |
| `setup-slack-channel` | `CopilotKit/CopilotKit`, installed via CLI | The provider half: Slack app creation, the managed Intelligence Channel, the local runtime, and diagnosing `setup_required`, Waiting for runtime, or a silent mention |

## Troubleshooting

<AccordionGroup>
<Accordion title="Unknown option '--skill'">

An older `copilotkit` — globally installed or left in the npx cache — is shadowing the current CLI. Keep the `@latest` in the command; that is what forces npx to fetch the current version instead of reusing what it already has.

</Accordion>
<Accordion title="The agent has no browser tool">

The hosted guide expects the agent to drive the Slack and Intelligence consoles in your signed-in session. An agent without a browser or computer-use tool will ask you to add one before continuing — add the tool rather than falling back to copying steps by hand.

</Accordion>
</AccordionGroup>

## Related pages

<CardGroup>
<Card title="Quickstart" href="/quickstart">
The same setup done by hand: create the Channel, write the listener, set the environment variables, and verify `status().overall === "online"`.
</Card>
<Card title="Installation" href="/installation">
The package pair, ESM requirement, `@ag-ui/client` overrides pin, and tsconfig the generated code depends on.
</Card>
<Card title="Managed Channels vs direct adapters" href="/managed-vs-direct">
Why the setup path defaults to a managed Channel, and why switching to a direct adapter to escape `setup_required` is a known failure mode.
</Card>
<Card title="Troubleshooting" href="/troubleshooting">
The stale-CLI error, `setup_required`, Waiting for runtime, and the other documented failure modes with fixes.
</Card>
</CardGroup>

---

## 05. Architecture and the runtime boundary

> How a turn flows: platform event → Intelligence ingress → your Channels process → agent over AG-UI → native UI back into the conversation. What you host (agent, tools, listener, state) versus what Intelligence manages (platform credentials, ingress, delivery, reconnects), and why a Channel needs a persistent gateway connection.

- Page Markdown: https://grok-wiki.com/public/docs/copilotkit-channels-sdk-4c947e3e6161/pages/05-architecture-and-the-runtime-boundary.md
- Generated: 2026-08-05T06:38:02.209Z

### Source Files

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

---
title: "Architecture and the runtime boundary"
description: "How a turn flows: platform event → Intelligence ingress → your Channels process → agent over AG-UI → native UI back into the conversation. What you host (agent, tools, listener, state) versus what Intelligence manages (platform credentials, ingress, delivery, reconnects), and why a Channel needs a persistent gateway connection."
---

A Channel splits one conversation turn across two systems. Your infrastructure runs the agent, tools, business logic, and a long-running Node.js 22+ process that hosts the `CopilotRuntime` listener. CopilotKit Intelligence holds the platform credentials, receives Slack and Microsoft Teams events over signed HTTPS ingress, and delivers each turn to your process over an outbound gateway WebSocket that your process opens and keeps alive. The SDK (`@copilotkit/channels`) is open source and MIT licensed; Intelligence can be hosted by CopilotKit or self-hosted for enterprise deployments.

## Anatomy of a turn

Every turn follows the same path, regardless of platform:

1. A person messages or @-mentions your app in Slack or Microsoft Teams.
2. Intelligence receives the platform event using its own credentials and delivers it down the gateway socket to your Channels process.
3. Your handler (`onMention`, `onMessage`, …) receives a `thread` handle and typically calls `thread.runAgent()`, which drives the agent over AG-UI — streaming, tool calls, and interrupts included.
4. The result — plain text or a JSX tree lowered to a neutral `ChannelNode[]` IR — goes back up, and Intelligence posts it as native platform UI (Block Kit on Slack, Adaptive Cards on Teams) into the conversation.

```mermaid
sequenceDiagram
    participant U as User (Slack / Teams)
    participant I as CopilotKit Intelligence
    participant C as Your Channels process<br/>(CopilotRuntime + createChannel)
    participant A as Agent (AG-UI)

    U->>I: Platform event (message, mention, click)
    Note over I: Signed HTTPS ingress,<br/>platform credentials held here
    I-->>C: Turn delivered over gateway WebSocket
    C->>C: Handler fires: onMention / onMessage
    C->>A: thread.runAgent() — run over AG-UI
    A-->>C: Streamed events, tool calls, interrupts
    C->>C: Tools execute in-process,<br/>JSX lowered to ChannelNode[] IR
    C-->>I: Rendered output up the same socket
    I->>U: Native UI posted into the conversation
```

The agent itself is anything AG-UI-compatible: the `BuiltInAgent` running in-process, a remote agent behind `HttpAgent` (as in `examples/minimal-channel/lib/channel.ts`), or LangGraph, CrewAI, Mastra, and other frameworks. Channels clones the agent per turn — pass a factory `(threadId) => agent` and never share one stateful instance across conversations.

## The ownership boundary

| You run | CopilotKit Intelligence manages |
| --- | --- |
| Your agent, model credentials, tools, and business logic | Slack and Microsoft Teams platform credentials |
| The long-running Channels listener | Platform ingress and credentialed delivery |
| Application state, deployment, and logs | Runtime registration, health, and reconnects |

The practical consequence for a managed Channel: your process carries no Slack or Teams tokens at all. The only credential it holds is a project-scoped `INTELLIGENCE_API_KEY`, and the only identifier binding it to a platform connection is the `CHANNEL_CODE` — the Channel Code declared in Intelligence, passed as `createChannel({ name })`. You add or remove platforms in the Intelligence dashboard, not in code.

The secondary path — direct adapters like `slack({ botToken, appToken })` over Socket Mode — moves platform tokens into your process, but the runtime still owns the Channel lifecycle and Intelligence is still required. See the managed-vs-direct page before reaching for it.

```mermaid
flowchart LR
    subgraph platform [Messaging platforms]
        SL[Slack]
        TM[Microsoft Teams]
    end

    subgraph intel [CopilotKit Intelligence — managed]
        ING[Signed HTTPS ingress]
        CRED[Platform credentials]
        GW[Gateway / delivery,<br/>registration, reconnects]
    end

    subgraph yours [Your infrastructure — you run]
        LST[createCopilotNodeListener<br/>+ CopilotRuntime]
        CH[createChannel<br/>handlers, tools, state]
        AG[Agent over AG-UI<br/>BuiltInAgent or HttpAgent]
    end

    SL --> ING
    TM --> ING
    ING --> GW
    GW <-- persistent WebSocket --> LST
    LST --> CH
    CH --> AG
    CRED -.used for egress.-> SL
    CRED -.used for egress.-> TM
```

## What runs inside your process

The minimal-channel example shows the full set of pieces you host, file by file:

| Piece | Where it lives | Responsibility |
| --- | --- | --- |
| Channel | `lib/channel.ts` | `createChannel({ name, agent })` plus handlers such as `onMention` and `onMessage` |
| Runtime + connection | `lib/runtime.ts` | `CopilotRuntime({ agents: {}, intelligence, channels: [channel] })` with a `CopilotKitIntelligence` client |
| Listener | `lib/runtime.ts` | `createCopilotNodeListener({ runtime, basePath: "/api/copilotkit" })` — creating it starts the Channel |
| Environment | `lib/env.ts` | Fail-fast loading of `INTELLIGENCE_API_KEY` and friends |
| Process lifecycle | `server.ts` | `channels.ready()`, the HTTP server, `channels.stop()` on `SIGINT` |

Two architectural facts follow from this layout:

- **There is no `channel.start()`.** The runtime owns the lifecycle. Attaching the Channel to `CopilotRuntime` and creating the listener is what activates it; `listener.channels` is the control surface for `ready()`, `status()`, and `stop()`.
- **`ready()` is not proof of life.** It also resolves when the Channel lands in `setup_required` — a declared-but-unprovisioned Channel is a valid degraded state. Gate deploys on `channels.status().overall === "online"` or you get a process that starts cleanly, serves HTTP 200, and answers nothing.

Tools (`defineChannelTool`) execute in this process too, with the live `thread` in their context — so a tool can post UI or block on a human choice mid-run. Per-thread state (`thread.state()` / `setState`) and any durable `store` adapter are likewise yours to host.

## Why the gateway connection must be persistent

Managed turn delivery arrives over the Channel's own outbound WebSocket to Intelligence — not over the HTTP port your listener serves. That connection is stateful and long-lived:

- **A serverless request handler cannot host a Channel.** Nothing would own the socket between invocations, so turns would have nowhere to land. A long-running Node process or container is required.
- **Node.js 22+ is required** because the launcher depends on the global `WebSocket`.
- **Reconnects are Intelligence-managed.** When the gateway socket drops, the SDK reports `reconnecting` in `channels.status()` while the connection layer retries; you do not write retry logic.
- **Keep the HTTP server anyway.** It serves the runtime's web requests, and most hosts require a listening port for their health check — it just isn't the delivery path.

The two endpoints are configured as separate hosts. Hosted Intelligence supplies both defaults from the API key alone; self-hosted deployments override both `INTELLIGENCE_API_URL` and `INTELLIGENCE_GATEWAY_WS_URL` together, as bare base URLs — the client appends its own paths, and neither URL is ever derived from the other by swapping the scheme.

```dotenv
INTELLIGENCE_API_KEY=<project-api-key>
CHANNEL_CODE=<channel-code-from-intelligence>
PORT=3000
# Paired overrides, self-hosted only:
# INTELLIGENCE_API_URL=https://intelligence.example.com
# INTELLIGENCE_GATEWAY_WS_URL=wss://realtime.intelligence.example.com
```

Teardown ordering mirrors startup: stop the Channel before closing the HTTP server, and wire the signal handlers before the listener exists so a Ctrl-C during the connect window still tears the Channel down.

```ts
// server.ts (examples/minimal-channel)
await listener.channels?.ready({ timeoutMs: 15_000 });
const server = createServer(listener);
server.listen(3000);

process.on("SIGINT", async () => {
  await listener.channels?.stop();
  server.close();
});
```

## Platform neutrality: the IR seam

The reason one handler works on every surface is a neutral intermediate representation. JSX from `@copilotkit/channels` lowers to `ChannelNode[]`; a platform adapter renders each node type to the native construct (Block Kit blocks, Adaptive Card elements, Discord components) and **skips** node types the surface cannot express — the renderer is total and never throws on an unsupported node. Ingress works the same way in reverse: the adapter decodes raw platform payloads into neutral turns, interactions, and commands before the engine sees them. On the managed path Intelligence performs this translation; on the direct-adapter path the adapter in your process does. Either way, your Channel logic — handlers, tools, JSX — never contains platform-specific code.

<Note>
The engine/adapter seam is a public contract. If you need a surface with no existing adapter, you implement `PlatformAdapter` (ingress sink, total egress renderer, `createRunRenderer` for live streaming, `decodeInteraction`, declared capabilities) and the Channel logic stays unchanged.
</Note>

## Related pages

<CardGroup cols={2}>
  <Card title="Channel lifecycle and status" href="/channel-lifecycle">
    The six SDK status values, why ready() resolves on setup_required, and teardown ordering in detail.
  </Card>
  <Card title="Managed Channels vs direct adapters" href="/managed-vs-direct">
    When to keep platform tokens out of your process, and when a direct adapter is actually warranted.
  </Card>
  <Card title="Minimal Channel example" href="/minimal-channel-example">
    The smallest complete listener, file by file, with the no-model-call verification trick.
  </Card>
  <Card title="Author a platform adapter" href="/author-platform-adapter">
    The full PlatformAdapter contract: ingress sink, total renderer, interaction decoding, capabilities.
  </Card>
</CardGroup>

---

## 06. Channel lifecycle and status

> The runtime owns the lifecycle — there is no channel.start(). Listener creation starts the Channel; channels.ready() settles activation but also resolves on setup_required; the six SDK status values (connecting, online, setup_required, reconnecting, error, stopped) versus the Intelligence dashboard states; teardown ordering for SIGINT/SIGTERM.

- Page Markdown: https://grok-wiki.com/public/docs/copilotkit-channels-sdk-4c947e3e6161/pages/06-channel-lifecycle-and-status.md
- Generated: 2026-08-05T06:37:45.249Z

### Source Files

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

---
title: "Channel lifecycle and status"
description: "The runtime owns the lifecycle — there is no channel.start(). Listener creation starts the Channel; channels.ready() settles activation but also resolves on setup_required; the six SDK status values (connecting, online, setup_required, reconnecting, error, stopped) versus the Intelligence dashboard states; teardown ordering for SIGINT/SIGTERM."
---

`@copilotkit/runtime` owns the Channel lifecycle end to end. A `Channel` returned by `createChannel` has no public `start()` or `stop()` method — you attach it to a `CopilotRuntime` via `channels: [channel]`, and creating the listener with `createCopilotNodeListener` is what activates it. From that point the lifecycle is observed and controlled through `listener.channels`: `ready()` to await activation settling, `status()` to read the current state, and `stop()` to tear the Channel down.

<Warning>
There is no `channel.start()` / `channel.stop()`. Calling either fails to compile — the runtime starts the Channel when the listener is created, and teardown goes through `listener.channels.stop()`.
</Warning>

## How a Channel starts

Attach the Channel to a `CopilotRuntime` and create a listener. Listener creation triggers activation: the runtime opens the outbound gateway socket to CopilotKit Intelligence and registers the Channel.

```ts title="lib/runtime.ts"
import { CopilotRuntime, CopilotKitIntelligence } from "@copilotkit/runtime/v2";
import { createCopilotNodeListener } from "@copilotkit/runtime/v2/node";
import { channel } from "@/lib/channel";

const intelligence = new CopilotKitIntelligence({
  apiKey: required("INTELLIGENCE_API_KEY"),
  apiUrl: process.env.INTELLIGENCE_API_URL,        // paired override, optional
  wsUrl: process.env.INTELLIGENCE_GATEWAY_WS_URL,  // never derive one from the other
});

const runtime = new CopilotRuntime({
  agents: {}, // required, even when the Channel supplies the agent
  intelligence,
  channels: [channel],
});

export const listener = createCopilotNodeListener({
  runtime,
  basePath: "/api/copilotkit",
});
```

`listener.channels` is non-optional when you pass a literal non-empty `channels: [channel]` array — that shape is branded, so no `!` or `?.` is needed under `strict`. A dynamically assembled `Channel[]` falls to the optional overload and does need `?.`.

Managed delivery arrives over the Channel's own gateway socket, not the HTTP port — but keep the HTTP server: it serves web requests through the runtime, and most hosts require a listening port for health checks. Because startup uses top-level `await`, the project must be ESM (`npm pkg set type=module`), and Node.js 22+ is required (the launcher needs global `WebSocket`).

## Lifecycle state machine

```mermaid
stateDiagram-v2
    [*] --> connecting: createCopilotNodeListener()
    connecting --> online: activation succeeds
    connecting --> setup_required: declared but unprovisioned
    connecting --> error: activation fails
    online --> reconnecting: gateway socket drops
    reconnecting --> online: connection layer retries
    online --> stopped: channels.stop()
    setup_required --> stopped: channels.stop()
    reconnecting --> stopped: channels.stop()
    stopped --> [*]
```

Activation errors are recorded as status and surfaced through `ready()` / `status()` — they are not thrown from the listener call. Only an up-front misconfiguration, such as a duplicate or missing Channel name, throws synchronously.

## channels.ready()

`ready()` blocks until activation settles. It accepts a timeout:

```ts
await listener.channels.ready({ timeoutMs: 30_000 });
```

<Warning>
`ready()` is **not** proof of life. It resolves on `setup_required` too, because a declared-but-unprovisioned Channel is a valid degraded state. Without a follow-up status check you get a process that starts cleanly, serves HTTP 200, and answers nothing.
</Warning>

Gate the deploy on the actual status:

```ts
await channels.ready({ timeoutMs: 30_000 });

const status = channels.status();
if (status.overall !== "online") {
  throw new Error(`Channel is not online: ${JSON.stringify(status)}`);
}
```

How `ready()` settles per outcome:

| Activation outcome | `ready()` behavior |
| --- | --- |
| `online` | Resolves |
| `setup_required` | Resolves (degraded but valid) |
| `error` | Rejects with the activation cause |
| Timeout elapsed | Rejects |

## channels.status()

`channels.status()` returns SDK lifecycle values as `overall` plus a per-Channel map.

| `status().overall` | Meaning |
| --- | --- |
| `connecting` | Activation in flight; `ready()` has not settled. |
| `online` | Connected. Send a real provider message to verify the full path. |
| `setup_required` | Declared but unprovisioned — finish the provider setup in Intelligence. `ready()` resolves here. |
| `reconnecting` | The gateway socket dropped; the connection layer is retrying. |
| `error` | Activation failed. `ready()` rejects with the cause. |
| `stopped` | `channels.stop()` was called. |

### SDK status versus Intelligence dashboard states

The six SDK values are **not** the same vocabulary as the Intelligence dashboard's Channel states: **Disabled, Setup incomplete, Setup failed, Waiting for runtime, Conflict, Offline, Delivery failing, Online**. The SDK reports what your process observes about its own activation and gateway connection; the dashboard reports what Intelligence observes about provisioning and delivery. A process can sit at `setup_required` locally while the dashboard shows **Setup incomplete**, or report `online` while the dashboard shows **Delivery failing**.

<Note>
Do not switch to a direct adapter to escape `setup_required` — it is a provisioning state, cleared by finishing the provider setup in Intelligence, not a reason to change the delivery path.
</Note>

## Teardown ordering for SIGINT/SIGTERM

Wire the signal handlers **before** the listener exists, because creating the listener starts the Channel. A Ctrl-C during the connect window then still tears the Channel down instead of hitting Node's default handler. Stop the Channel first, then close the HTTP server:

```ts title="server.ts (recommended ordering)"
let teardown: (() => Promise<void>) | undefined;
const shutdown = async () => { await teardown?.(); };
process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown);

const listener = createCopilotNodeListener({ runtime, basePath: "/api/copilotkit" });
const channels = listener.channels;
const server = createServer(listener);
teardown = async () => {
  await channels.stop();
  if (server.listening) server.close();
};

await channels.ready({ timeoutMs: 30_000 });
const status = channels.status();
if (status.overall !== "online") {
  throw new Error(`Channel is not online: ${JSON.stringify(status)}`);
}

server.listen(Number(process.env.PORT ?? 3000));
```

The `examples/minimal-channel` server shows the compact form of the same shape — `ready()` gate, HTTP server, `SIGINT` teardown:

```ts title="examples/minimal-channel/server.ts"
import { createServer } from "node:http";
import { listener } from "@/lib/runtime";

await listener.channels?.ready({ timeoutMs: 15_000 });

const server = createServer(listener);

server.listen(3000, () => {
  console.log("\nServer listening on port 3000...");
});

process.on("SIGINT", async () => {
  await listener.channels?.stop();
  server.close();
});
```

## Verifying a live Channel

`status().overall === "online"` confirms activation, not the full delivery path. To verify end to end without spending a model call, use the minimal-channel pattern: an `onMention` handler that posts a single `🪁` instead of running the agent. When Intelligence shows **Online**, mention the app on the provider — a kite in the thread confirms ingress, your process, and delivery all work. Then swap the handler body for `thread.runAgent()`.

## Lifecycle rules summary

- Do not call `channel.start()` / `channel.stop()` — no public lifecycle method exists on `Channel`. Attach it to `CopilotRuntime` and create a listener.
- Wire `SIGINT`/`SIGTERM` handlers before creating the listener.
- Do not treat `await ready()` as proof of life — gate on `status().overall === "online"`.
- Activation errors surface through `ready()` rejection and `status()`, not as thrown exceptions from listener creation.
- Attach handlers (`onMention`, `onMessage`, …) to the Channel before the runtime starts it.

## Related pages

<CardGroup>
  <Card title="Minimal Channel example" href="/minimal-channel-example">
    The smallest complete listener with the ready() gate, SIGINT teardown, and the 🪁 no-model-call verification trick.
  </Card>
  <Card title="Architecture and the runtime boundary" href="/architecture">
    Why a Channel needs a persistent gateway connection, and what you host versus what Intelligence manages.
  </Card>
  <Card title="Managed Channels vs direct adapters" href="/managed-vs-direct">
    Why switching to a direct adapter to escape setup_required is a known failure mode.
  </Card>
  <Card title="Troubleshooting" href="/troubleshooting">
    setup_required, Waiting for runtime, and ready() resolving on a degraded Channel — with fixes.
  </Card>
</CardGroup>

---

## 07. Managed Channels vs direct adapters

> The managed default (no adapter, Channel Code from Intelligence, no platform tokens in your process) versus the direct-adapter path (adapters: [slack({ botToken, appToken })], Socket Mode, defaultSlackTools/defaultSlackContext). When each applies, and why switching to a direct adapter to escape setup_required is a known failure mode.

- Page Markdown: https://grok-wiki.com/public/docs/copilotkit-channels-sdk-4c947e3e6161/pages/07-managed-channels-vs-direct-adapters.md
- Generated: 2026-08-05T06:38:17.756Z

### Source Files

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

---
title: "Managed Channels vs direct adapters"
description: "The managed default (no adapter, Channel Code from Intelligence, no platform tokens in your process) versus the direct-adapter path (adapters: [slack({ botToken, appToken })], Socket Mode, defaultSlackTools/defaultSlackContext). When each applies, and why switching to a direct adapter to escape setup_required is a known failure mode."
---

`createChannel` supports two ways to reach a platform. The default is a **managed Channel**: no `adapters` option at all, `name` set to the Channel Code from CopilotKit Intelligence, and no Slack or Teams tokens anywhere in your process — Intelligence holds the platform credentials and you add platforms in the dashboard rather than in code. The secondary path is a **direct adapter**: `adapters: [slack({ botToken, appToken })]`, where you hold the platform tokens yourself and Slack delivery runs over Socket Mode. Both paths attach the Channel to a `CopilotRuntime` with a `CopilotKitIntelligence` connection — a direct adapter changes who owns the platform credentials, not who owns the lifecycle.

## The two paths at a glance

| Aspect | Managed (default) | Direct adapter |
| --- | --- | --- |
| `adapters` option | Absent | `[slack({ botToken, appToken })]` |
| `name` | Must equal the exact Channel Code from Intelligence | Free-form (Channel Code rules do not gate it) |
| Platform tokens in your process | None | `SLACK_BOT_TOKEN` (`xoxb-…`) and `SLACK_APP_TOKEN` (`xapp-…`) |
| Slack delivery | Signed HTTPS ingress into Intelligence + outbound gateway socket | Socket Mode via the `xapp-` app token |
| Adding a platform | In the Intelligence dashboard | In code, per adapter |
| Intelligence API key | Required | Still required — the runtime owns the lifecycle |
| Slash commands | Not part of the managed product surface | Available through the adapter |
| `showToolStatus` | `createChannel({ showToolStatus: true })` | Ignored on `createChannel` — pass `slack({ showToolStatus: true })` |

```mermaid
flowchart LR
  subgraph slackPlatform["Slack"]
    events["Platform events"]
  end
  subgraph intelligence["CopilotKit Intelligence"]
    ingress["Signed HTTPS ingress"]
    lifecycle["Runtime registration,\nhealth, reconnects"]
    creds["Slack / Teams credentials\n(managed path only)"]
  end
  subgraph yourProcess["Your long-running Node process"]
    channel["createChannel(...)"]
    runtime["CopilotRuntime + listener"]
    adapter["slack({ botToken, appToken })\n(direct path only)"]
  end
  events -- "managed" --> ingress
  ingress -- "gateway socket" --> channel
  events <-- "direct: Socket Mode" --> adapter
  adapter --> channel
  runtime --- lifecycle
```

## The managed default

A managed Channel has no adapter. `name` binds the process to a Channel declared in Intelligence, and Intelligence delivers each turn over the Channel's gateway socket:

```ts
import { createChannel } from "@copilotkit/channels";
import { makeAgent } from "./agent.js";

const channel = createChannel({
  name: process.env.CHANNEL_CODE!, // must equal the Channel Code in Intelligence
  identifyUser: "platform",        // required
  agent: makeAgent,                // factory: (threadId) => agent
});
```

Constraints that only apply on this path:

- **`name` must be the exact Channel Code**: 3–64 characters, starting with a lowercase letter, lowercase letters and digits separated by single hyphens, project-unique, and never the literal `channels`. The runtime validates it, not `createChannel` — a typo fails at startup and leaves the Channel at **Waiting for runtime** in the dashboard.
- **No `xapp-` token, ever.** Socket Mode belongs only to the direct-adapter path. Managed delivery uses signed HTTPS ingress into Intelligence plus an outbound gateway socket, so a managed setup needs no app token.
- **Managed Slack hides tool-call progress by default.** Opt in with `createChannel({ showToolStatus: true })`; the lifecycle events still land in Intelligence history either way.

The `examples/minimal-channel` example is this shape end to end: `createChannel` with no `adapters`, a `CopilotRuntime({ agents: {}, intelligence, channels: [channel] })`, and a `createCopilotNodeListener` whose creation starts the Channel.

## The direct-adapter path

Pass `adapters` only when *you* own the platform connection — you created the Slack app and you hold the bot and app tokens, and you do not want Intelligence holding the platform credentials. This puts platform secrets in your app and per-platform wiring in code:

```ts
import { slack, defaultSlackTools, defaultSlackContext } from "@copilotkit/channels/slack";

const channel = createChannel({
  name: "support-slack",
  identifyUser: "platform",
  adapters: [
    slack({
      botToken: process.env.SLACK_BOT_TOKEN!, // xoxb-…
      appToken: process.env.SLACK_APP_TOKEN!, // xapp-… (Socket Mode)
    }),
  ],
  agent: makeAgent,
  tools: [...defaultSlackTools /* , ...yourTools */],
  context: [...defaultSlackContext /* , ...yourContext */],
});
```

- `adapters` is an array — one Channel can run several platforms at once. Adapters live on subpaths: `@copilotkit/channels/slack`, `/teams`, `/discord`, `/telegram`, `/whatsapp`.
- `defaultSlackTools` / `defaultSlackContext` add the `lookup_slack_user` tool plus tagging, mrkdwn, and threading guidance. Include them for direct Slack — a managed Channel gets equivalent behavior from Intelligence.
- `showToolStatus` set on `createChannel` is **ignored for direct-adapter Channels**; configure it on the adapter as `slack({ showToolStatus: true })`.
- `replyContinuation` (long-reply splitting and truncation) is honored by both managed and direct Slack.

<Warning>
A direct adapter does **not** remove the Intelligence requirement. There is no `channel.start()` and no standalone mode — you still attach the Channel to a `CopilotRuntime` with a `CopilotKitIntelligence` connection and create the listener, exactly as on the managed path. Claiming the direct adapter runs standalone is one of the failure modes the repository's own eval suite tests for.
</Warning>

## Choosing a path

Default to managed. The direct adapter is justified by exactly one condition: your organization must hold the Slack app and its tokens itself. Signals that point at each path:

- **Managed** — nothing in your requirements says you hold platform tokens; you want to add Teams later without code changes; you want zero platform secrets in your deployment; you are following the quickstart or the `setup-slack-channel` workflow.
- **Direct** — you already have your own Slack app, you hold `xoxb-`/`xapp-` tokens, and a policy or architecture decision requires that CopilotKit never hold the platform credentials.

## `setup_required` is not a reason to switch

A managed Channel that reports `setup_required` is **declared but unprovisioned** — the Channel exists in Intelligence but the provider setup (the Slack connection) is unfinished. Swapping to a direct adapter to "make it work" is a known failure mode, not a fallback: it trades an incomplete dashboard step for a different architecture with token custody you did not plan for, and the underlying setup gap remains for every future managed Channel. Fix the managed setup instead — the `setup-slack-channel` skill covers diagnosing a Channel stuck at `setup_required`, sitting at Waiting for runtime, or Online but silent.

Two related traps compound this one:

- **`channels.ready()` resolves on `setup_required`.** A declared-but-unprovisioned Channel is a valid degraded state, so `await ready()` is not proof of life. Gate startup on `channels.status().overall === "online"`, or the process starts cleanly, serves HTTP 200, and answers nothing.
- **A `name` mismatch looks similar but is a different state.** A Channel Code typo shows **Waiting for runtime** in the dashboard, not `setup_required`. Check the code matches exactly before touching anything else.

```ts
await channels.ready({ timeoutMs: 30_000 });

const status = channels.status();
if (status.overall !== "online") {
  throw new Error(`Channel is not online: ${JSON.stringify(status)}`);
}
```

## Related pages

<CardGroup>
  <Card title="Channel lifecycle and status" href="/channel-lifecycle">
    The six SDK status values, why ready() resolves on setup_required, and how they map to the Intelligence dashboard states.
  </Card>
  <Card title="Architecture and the runtime boundary" href="/architecture">
    What you host versus what Intelligence manages, and why a Channel needs a persistent gateway connection.
  </Card>
  <Card title="createChannel reference" href="/createchannel-reference">
    Every createChannel option, including adapters, Channel Code naming rules, and showToolStatus.
  </Card>
  <Card title="Set up with a coding agent" href="/coding-agent-setup">
    The setup workflow that provisions a managed Channel correctly instead of routing around setup_required.
  </Card>
  <Card title="Troubleshooting" href="/troubleshooting">
    setup_required, Waiting for runtime, and the other documented failure modes with fixes.
  </Card>
</CardGroup>

---

## 08. 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.

- Page Markdown: https://grok-wiki.com/public/docs/copilotkit-channels-sdk-4c947e3e6161/pages/08-handle-mentions-messages-and-subscriptions.md
- Generated: 2026-08-05T06:38:06.956Z

### 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>

---

## 09. Add tools

> Define typed agent tools with defineChannelTool and any Standard Schema validator (Zod, Valibot, ArkType): the ChannelToolContext shape ({ thread, message?, user, actor, signal?, platform }), return-value rules (raw data back to the agent, error text on failure), and registration via createChannel({ tools }) or channel.tool().

- Page Markdown: https://grok-wiki.com/public/docs/copilotkit-channels-sdk-4c947e3e6161/pages/09-add-tools.md
- Generated: 2026-08-05T06:39:43.566Z

### Source Files

- `.agents/skills/build-channels-agent/SKILL.md`
- `.agents/skills/build-channels-agent/evals/evals.json`
- `.agents/skills/build-channels-agent/references/hitl-patterns.md`
- `README.md`

---
title: "Add tools"
description: "Define typed agent tools with defineChannelTool and any Standard Schema validator (Zod, Valibot, ArkType): the ChannelToolContext shape ({ thread, message?, user, actor, signal?, platform }), return-value rules (raw data back to the agent, error text on failure), and registration via createChannel({ tools }) or channel.tool()."
---

`defineChannelTool` from `@copilotkit/channels` declares a typed function the agent can call during a run. A tool is a plain object with a `name`, a `description`, a `parameters` schema, and an async `handler`. The `parameters` field accepts any [Standard Schema](https://standardschema.dev) validator — Zod, Valibot, and ArkType all work — and the handler receives the parsed, typed arguments plus a `ChannelToolContext` that carries the **live thread**, so a tool can post UI, ask a question, or run a human-in-the-loop flow mid-execution.

<Warning>
The API is `defineChannelTool`, not `defineBotTool`. Bot-prefixed names (`createBot`, `defineBotTool`) do not exist in this SDK and are a documented hallucination pattern — see the troubleshooting page.
</Warning>

## Define a tool

```ts channel.ts
import { defineChannelTool } from "@copilotkit/channels";
import { z } from "zod";

const getOncall = defineChannelTool({
  name: "get_oncall",
  description: "Look up who is currently on call for a team.",
  parameters: z.object({ team: z.string() }),
  async handler({ team }, { thread, user, actor, signal, platform }) {
    return await fetchOncall(team); // returned value goes back to the agent
  },
});
```

<ParamField body="name" type="string" required>
Tool name the model calls, e.g. `get_oncall`.
</ParamField>

<ParamField body="description" type="string" required>
What the tool does — this is the model's only guidance for when to call it.
</ParamField>

<ParamField body="parameters" type="StandardSchema" required>
Any Standard Schema validator (Zod, Valibot, ArkType). The handler receives the parsed output type; invalid arguments never reach your code.
</ParamField>

<ParamField body="handler" type="(args, ctx: ChannelToolContext) => Promise<unknown>" required>
Async function receiving the validated arguments and the tool context. Its return value is serialized back to the agent.
</ParamField>

## ChannelToolContext

The second handler argument is `ChannelToolContext = { thread, message?, user, actor, signal?, platform }`:

<ResponseField name="thread" type="Thread">
The live per-conversation handle. Everything on the Thread API is available mid-tool-call: `post`, `update`, `awaitChoice`, `postFile`, `subscribe`, and the rest.
</ResponseField>

<ResponseField name="message" type="Message | undefined">
The triggering message, when the run originated from one. Optional — not every run starts from a message.
</ResponseField>

<ResponseField name="user" type="ApplicationUser | null">
The canonical user resolved by `identifyUser`. Note that the `onMention`/`onMessage` handlers receive only `{ thread, message }`; the tool context is one of the places (alongside `onThreadStarted` and `onWelcome`) where the resolved `user` is exposed directly.
</ResponseField>

<ResponseField name="actor" type="Actor">
The platform-level actor behind the turn.
</ResponseField>

<ResponseField name="signal" type="AbortSignal | undefined">
Cancellation signal for the run. Pass it to long-running fetches so an aborted turn stops your work.
</ResponseField>

<ResponseField name="platform" type="string">
The surface the turn is running on (e.g. Slack, Teams), for platform-conditional behavior.
</ResponseField>

## Return-value rules

The return value is what the **agent** reads back — the user never sees it directly.

| Situation | Return |
| --- | --- |
| Data lookup | The raw data. It is JSON-stringified for you — do not hand-stringify, and do not return `{ ok: true }`. |
| Tool posted UI itself | A short natural-language confirmation such as `"Displayed the issue card."` so the model doesn't restate the card's content in prose. |
| Failure | The actual error text, so the model can repair its arguments and retry. Do not throw away the message or return a bare boolean. |

## Register tools

<Tabs>
<Tab title="createChannel({ tools })">

```ts
import { createChannel } from "@copilotkit/channels";

const channel = createChannel({
  name: process.env.CHANNEL_CODE!,
  identifyUser: "platform",
  agent: makeAgent,
  tools: [getOncall],
});
```

</Tab>
<Tab title="channel.tool()">

```ts
// Must happen before the runtime starts the Channel
channel.tool(getOncall);
```

Registration via `channel.tool(t)` is equivalent, but it has to run before the listener is created — creating the listener is what starts the Channel.

</Tab>
<Tab title="Per-run via runAgent">

```ts
await thread.runAgent({
  prompt,
  tools: [oneOffTool], // extra ChannelTool[] for this run only
});
```

`thread.runAgent({ tools })` adds tools for a single run on top of the Channel-level registration.

</Tab>
</Tabs>

On the direct Slack adapter path, spread in the SDK's defaults alongside your own — `defaultSlackTools` adds `lookup_slack_user` plus tagging, mrkdwn, and threading guidance:

```ts
import { slack, defaultSlackTools, defaultSlackContext } from "@copilotkit/channels/slack";

const channel = createChannel({
  name: "support-slack",
  identifyUser: "platform",
  adapters: [slack({ botToken, appToken })],
  agent: makeAgent,
  tools: [...defaultSlackTools, getOncall],
  context: [...defaultSlackContext],
});
```

<Note>
Tools and context solve different problems. A `ContextEntry` (`{ description, value }`) is injected into the agent's prompt every run — use it for standing facts like the channel or the caller's role. A tool is a function the model chooses to call. Register context via `createChannel({ context })` or `thread.runAgent({ context })`.
</Note>

## Gate a tool on human approval

Because the handler holds the live `thread`, it can block on a typed button choice before doing anything irreversible. `thread.awaitChoice<T>` posts the UI and suspends the handler until a control is activated, resolving to that control's `value`:

```tsx delete-tool.tsx
import { defineChannelTool, Message, Section, Markdown, Actions, Button } from "@copilotkit/channels";
import { z } from "zod";

const dropDatabase = defineChannelTool({
  name: "drop_database",
  description: "Delete a database after human confirmation.",
  parameters: z.object({ db: z.string() }),
  async handler({ db }, { thread }) {
    const ok = await thread.awaitChoice<boolean>(
      <Message accent="#E01E5A">
        <Section><Markdown>Delete **{db}**? This is irreversible.</Markdown></Section>
        <Actions>
          <Button value={true} style="primary">Approve</Button>
          <Button value={false} style="danger">Cancel</Button>
        </Actions>
      </Message>,
    );
    if (!ok) return "User cancelled; nothing was deleted.";
    await deleteDatabase(db);
    return `Deleted ${db}.`;
  },
});
```

Files containing JSX must be `.tsx`, and the tsconfig must set `jsxImportSource: "@copilotkit/channels"` — this JSX runtime is not React and declares no lowercase intrinsic tags (`<b>`, `<span>` are compile errors; emphasis goes inside `<Markdown>`).

## Tool-call progress in the conversation

Managed Slack hides tool-call progress by default; the conversation shows only the clean result, while lifecycle events still land in Intelligence history. Opt in per Channel:

```ts
const channel = createChannel({ /* … */ showToolStatus: true });
```

`showToolStatus` is ignored for direct-adapter Channels — configure those on the adapter instead, e.g. `slack({ showToolStatus: true })`.

## Components as agent-callable tools

From 0.7+, `defineChannelComponent` turns a JSX component into a tool the agent can call to render UI itself, with props inferred from the same Standard Schema mechanism. Pass those via `createChannel({ components })`, not `tools` — registration also lets keyed handlers be recovered after a restart when the store is durable.

## Related pages

<CardGroup cols={2}>
  <Card title="Human-in-the-loop approvals" href="/human-in-the-loop">
    `awaitChoice` in depth, `onInterrupt` + `thread.resume`, and making approval buttons survive restarts.
  </Card>
  <Card title="createChannel reference" href="/createchannel-reference">
    Every `createChannel` option, including `tools`, `context`, `components`, and `showToolStatus`.
  </Card>
  <Card title="Thread API reference" href="/thread-api-reference">
    The full `thread` surface available inside a tool handler, including capability-gated methods.
  </Card>
  <Card title="Render interactive UI" href="/render-interactive-ui">
    The JSX vocabulary tools can post, and `defineChannelComponent` for agent-rendered UI.
  </Card>
  <Card title="Handle mentions, messages, and subscriptions" href="/handle-conversations">
    The channel handlers that trigger the runs where your tools execute.
  </Card>
  <Card title="Troubleshooting" href="/troubleshooting">
    Known failure modes, including the invented Bot-prefixed APIs and JSX-against-React compile errors.
  </Card>
</CardGroup>

---

## 10. Render interactive UI

> Post one JSX tree that lowers to Block Kit, Adaptive Cards, or Discord components: thread.post/update/delete, inline onClick/onSelect handlers with content-stable IDs, graceful degradation on surfaces that skip unsupported nodes, and agent-rendered components via defineChannelComponent (0.7+).

- Page Markdown: https://grok-wiki.com/public/docs/copilotkit-channels-sdk-4c947e3e6161/pages/10-render-interactive-ui.md
- Generated: 2026-08-05T06:39:58.537Z

### Source Files

- `.agents/skills/build-channels-agent/SKILL.md`
- `.agents/skills/build-channels-agent/references/ui-components.md`
- `.agents/skills/build-channels-agent/references/hitl-patterns.md`
- `examples/minimal-channel/tsconfig.json`

---
title: "Render interactive UI"
description: "Post one JSX tree that lowers to Block Kit, Adaptive Cards, or Discord components: thread.post/update/delete, inline onClick/onSelect handlers with content-stable IDs, graceful degradation on surfaces that skip unsupported nodes, and agent-rendered components via defineChannelComponent (0.7+)."
---

Message UI in the Channels SDK is JSX imported from `@copilotkit/channels` and passed to `thread.post`, `thread.update`, or `thread.awaitChoice`. The engine lowers the tree to a platform-neutral IR (`ChannelNode[]`); each adapter then renders that IR natively — Block Kit on Slack, Adaptive Cards on Microsoft Teams, message components on Discord — and skips any node its surface cannot express. The renderer is total by contract, so one rich tree degrades gracefully across platforms instead of throwing. This is not React: the JSX runtime is the Channels package itself, configured through `jsxImportSource`.

## One tree, every surface

```text
Your process                          Adapter boundary
┌──────────────────────────────┐     ┌───────────────────────────────┐
│ JSX tree (<Message>…)        │     │ Slack    → Block Kit          │
│   │ lower                    │     │ Teams    → Adaptive Cards     │
│   ▼                          │ ──▶ │ Discord  → message components │
│ ChannelNode[]  (neutral IR)  │     │ (unsupported nodes: skipped)  │
└──────────────────────────────┘     └───────────────────────────────┘
```

A complete interactive message — layout blocks, a link button, and a button with an inline handler:

```tsx
import {
  Message, Header, Section, Markdown, Fields, Field,
  Actions, Button,
} from "@copilotkit/channels";

await thread.post(
  <Message accent="#ff6600">
    <Header>Top story</Header>
    <Section><Markdown>**{story.title}** — {story.points} points</Markdown></Section>
    <Fields>
      <Field label="Author">{story.by}</Field>
      <Field label="Comments">{story.descendants}</Field>
    </Fields>
    <Actions>
      <Button url={story.url}>Open link</Button>
      <Button value={story.id} style="primary" onClick={async ({ action, thread }) => {
        await thread.post(<Section>Summarizing {action.value}…</Section>);
      }}>Summarize</Button>
    </Actions>
  </Message>,
);
```

Children may be nested elements, strings, numbers, arrays, or conditionals — `false`, `null`, and `undefined` render nothing.

<Warning>
Do not hand-build Block Kit, Adaptive Cards, or Discord embed JSON. Render JSX and let the adapter translate it. A made-up tag or prop will not lower to a valid IR node — stay inside the documented component vocabulary.
</Warning>

## JSX runtime setup

Files containing JSX must use the `.tsx` extension, and the tsconfig must point the JSX factory at Channels:

```json
{
  "compilerOptions": {
    "target": "ES2022",
    "jsx": "react-jsx",
    "jsxImportSource": "@copilotkit/channels",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "strict": true,
    "types": ["node"]
  }
}
```

Constraints that differ from React:

- Without `jsxImportSource`, the tree compiles against React and fails.
- There are no lowercase intrinsic tags — the runtime declares an empty `IntrinsicElements`, so `<b>`, `<span>`, and `<div>` are compile errors. Emphasis goes inside `<Markdown>`.
- Point `jsxImportSource` at `@copilotkit/channels`, not `@copilotkit/channels-ui`. The `-ui` package is only a transitive dependency of the umbrella package; importing it directly resolves under npm's hoisted layout but fails under pnpm unless you install it as a direct dependency.

## Post, update, delete

`thread.post(ui)` renders a tree and returns a `MessageRef`. The ref is the handle for later edits:

```ts
thread.post(ui)          // render a JSX message → MessageRef
thread.update(ref, ui)   // replace a previously posted message
thread.delete(ref)       // remove a message
thread.stream(src)       // stream a string / AsyncIterable<string> live
```

A common pattern is updating a message in place from its own button handler, using the `messageRef` the handler context provides:

```tsx
<Button value="approve" style="primary"
  onClick={async ({ action, thread, messageRef }) => {
    await thread.update(messageRef, <Section>Approved ✓</Section>);
  }}>
  Approve
</Button>
```

<Warning>
Handlers must return `void | Promise<void>`, and `post` returns a `MessageRef`. A concise arrow like `({ thread }) => thread.post(...)` therefore fails under `strict`. Use a block body: `async ({ thread }) => { await thread.post(…); }`.
</Warning>

## Component vocabulary

All components import from `@copilotkit/channels` (the root re-exports them; `/ui` is the same surface). The full prop and degradation reference is on the [UI components reference](/ui-components-reference) page; the working set:

| Category | Components | Use for |
| --- | --- | --- |
| Layout & content | `Message`, `Header`, `Section`, `Markdown`, `Fields`/`Field`, `Context`, `Divider`, `Image`, `Table`/`Row`/`Cell`, `Chart` | Announce and inform |
| Interactive | `Actions`, `Button`, `Select`, `Input` | Discrete choices, option lists, free text |
| Modal | `Modal`, `TextInput`, `ModalSelect`, `ModalSelectOption`, `RadioButtons` | Structured forms (separate IR root — see below) |

Choosing among them: announce with `<Message>` + `<Header>`/`<Section>`/`<Markdown>`/`<Fields>`; offer discrete choices with `<Actions>` and `<Button>`s (or `thread.awaitChoice`); collect free text or a pick from a list with `<Input>`/`<Select>`; show structured data with `<Table>` or `<Chart>`.

## Inline handlers and content-stable IDs

`<Button onClick>`, `<Select onSelect>`, `<Input onSubmit>`, and `<Message onReaction>` take inline handlers. Each handler receives a context with at least:

<ResponseField name="action.value" type="T">
  The value echoed back from the control — typed from the `Button`'s `value` prop, or the selection (`string`, or `string[]` when `<Select multi>`).
</ResponseField>

<ResponseField name="thread" type="Thread">
  The live thread — a handler can `thread.post(...)`, `thread.update(...)`, or run a human-in-the-loop flow.
</ResponseField>

<ResponseField name="messageRef" type="MessageRef">
  A ref to the message the control lives in, for in-place `update`.
</ResponseField>

<ResponseField name="user" type="ApplicationUser | null">
  The user who activated the control.
</ResponseField>

Handlers are keyed by **content-stable IDs**: `"ck:" + sha1(name | path | stableStringify(props)).slice(0, 16)`. The same rendered control always produces the same ID, so a button clicked long after it was posted still resolves to the right handler — as long as the binding still exists.

Where the binding lives determines durability:

| Configuration | After a restart |
| --- | --- |
| Inline closures + default in-memory `MemoryStore` | Bindings lost — a click on a pre-restart message degrades to "action expired" |
| Registered component (`createChannel({ components })`) + durable store (`createChannel({ store: { adapter } })`) | Handler is re-bound; the click resolves |

```ts
const channel = createChannel({
  identifyUser: "platform",
  store: {
    adapter: myRedisStore,                       // your StateStore implementation
    actionRetentionMs: 7 * 24 * 60 * 60 * 1000,  // default 7 days
  },
  components: [IssueCard],  // registration is required for re-binding
});
```

Rule of thumb: in-memory is fine for a demo or a short-lived prompt. For buttons that must work hours later or across deploys, configure a durable store and use registered components rather than one-off inline closures. The deprecated `createChannel({ actionStore })` still works — prefer `store.adapter`.

## Graceful degradation

Message rendering never throws on an unsupported node — the adapter contract requires a **total renderer** that skips what its surface can't express. Concrete examples of how the same tree lands differently:

- `<Chart>` renders natively where the platform supports charts; platforms without native charts skip the node.
- `<Select multi>` renders as `multi_static_select` on Slack, max-values on Discord, `isMultiSelect` on Teams; Telegram and WhatsApp degrade to single-select.
- `<Field label>` renders the label on Slack, Discord, and Teams; surfaces without field labels fall back to the value text alone.

Capability-gated thread methods follow the same philosophy: `thread.getMessages()` returns `[]` and `thread.lookupUser()` returns `undefined` where the adapter cannot do it, rather than throwing.

<Note>
Modals are the exception. A modal is a separate IR root (`ModalView`) opened with `ctx.openModal(...)` from a command context, and an adapter throws `ModalRenderError` if the view uses an element its surface can't express — modal rendering is not skip-and-degrade. Modal submissions route by `callbackId` to `channel.onModalSubmit`/`onModalClose`, not to inline handlers, and the root must be built by calling `Modal({...})` as a function, not `<Modal>` JSX. See [Slash commands and modals](/slash-commands-and-modals).
</Note>

## Agent-rendered components (0.7+)

`defineChannelComponent` turns a component into a tool the agent can call to render UI itself, with props inferred from a [Standard Schema](https://standardschema.dev) validator. It exists in `@copilotkit/channels@0.7+` only — the 0.6.x pair pinned by the Slack guide does not have it.

```tsx
import { defineChannelComponent, Message, Header, Context } from "@copilotkit/channels";
import { z } from "zod";

const IssueCard = defineChannelComponent({
  name: "issue_card",
  description: "Render an issue as a card.",
  parameters: z.object({ id: z.string(), title: z.string() }),
  render({ id, title }, { platform, signal }) {
    return <Message><Header>{title}</Header><Context>{id}</Context></Message>;
  },
});
```

<ParamField body="name" type="string" required>
  The tool name the agent calls to render this component.
</ParamField>

<ParamField body="description" type="string" required>
  Tells the agent when to render it.
</ParamField>

<ParamField body="parameters" type="StandardSchema" required>
  Any Standard Schema validator (Zod, Valibot, ArkType); the parsed value becomes the `render` props.
</ParamField>

<ParamField body="render" type="(props, ctx) => JSX">
  Returns the JSX tree. The context carries `platform` and `signal`.
</ParamField>

Register it via `createChannel({ components: [IssueCard] })`. Registration serves double duty: it exposes the component to the agent, and it lets keyed handlers be recovered after a restart when the store is durable.

## Common mistakes

- Writing JSX in a `.ts` file, or omitting `jsxImportSource: "@copilotkit/channels"` — the tree compiles against React and fails.
- Using lowercase HTML tags (`<b>`, `<div>`) — compile errors; use `<Markdown>` for emphasis.
- Hand-building Block Kit / Adaptive Cards / embed JSON instead of JSX.
- Inventing component names or props beyond the documented vocabulary — they will not lower to valid IR nodes.
- Returning `thread.post(...)` from a concise arrow handler — use a block body and `await`.
- Expecting inline closures to survive a restart — durability requires a registered component plus a durable `store.adapter`.
- Building a modal as `<Modal>` JSX — `JSX.Element` is `ChannelNode`, which erases the `ModalView` narrowing `openModal` requires; call `Modal({ …, children: [...] })`.

## Related pages

<CardGroup cols={2}>
  <Card title="UI components reference" href="/ui-components-reference">
    Every component with full props, handler context shapes, and per-platform degradation rules.
  </Card>
  <Card title="Human-in-the-loop approvals" href="/human-in-the-loop">
    Block a tool on a typed button choice with `thread.awaitChoice<T>`, and make approvals survive restarts.
  </Card>
  <Card title="Slash commands and modals" href="/slash-commands-and-modals">
    Open modals with `ctx.openModal` and route submissions by `callbackId`.
  </Card>
  <Card title="Thread API reference" href="/thread-api-reference">
    The full per-conversation handle: post, update, stream, runAgent, awaitChoice, and capability gating.
  </Card>
  <Card title="Author a platform adapter" href="/author-platform-adapter">
    The `PlatformAdapter` contract behind rendering: total renderers, `decodeInteraction`, and declared capabilities.
  </Card>
  <Card title="createChannel reference" href="/createchannel-reference">
    The `components` and `store` options that make rendered UI durable.
  </Card>
</CardGroup>

---

## 11. Human-in-the-loop approvals

> Gate agent actions on humans: thread.awaitChoice<T> to block a tool handler on a typed button choice, onInterrupt + thread.resume for agent-originated pauses (LangGraph-style interrupts), and making approval buttons survive restarts with a durable StateStore adapter plus registered components.

- Page Markdown: https://grok-wiki.com/public/docs/copilotkit-channels-sdk-4c947e3e6161/pages/11-human-in-the-loop-approvals.md
- Generated: 2026-08-05T06:39:14.045Z

### Source Files

- `.agents/skills/build-channels-agent/references/hitl-patterns.md`
- `.agents/skills/build-channels-agent/SKILL.md`
- `.agents/skills/build-channels-agent/references/ui-components.md`
- `.agents/skills/build-channels-agent/evals/evals.json`

---
title: "Human-in-the-loop approvals"
description: "Gate agent actions on humans: thread.awaitChoice<T> to block a tool handler on a typed button choice, onInterrupt + thread.resume for agent-originated pauses (LangGraph-style interrupts), and making approval buttons survive restarts with a durable StateStore adapter plus registered components."
---

The Channels SDK pauses a run and waits for a human through two mechanisms, chosen by where the pause originates. `thread.awaitChoice<T>(ui)` is for when **your code** asks: it posts a JSX tree and blocks the calling handler until the user activates a control, resolving to that control's `value` typed as `T`. `channel.onInterrupt<T>(event, fn)` plus `thread.resume(value)` is for when **the agent** pauses itself mid-`thread.runAgent()` — a LangGraph-style interrupt. Whether the approval buttons still work after a process restart depends on a third piece: the configured store and component registration.

| Mechanism | Pause originates in | Blocks | Continues via |
| --- | --- | --- | --- |
| `thread.awaitChoice<T>(ui)` | Your handler or tool code | The calling handler | The returned promise resolving to the clicked `value` |
| `channel.onInterrupt<T>(event, fn)` | The agent, during `thread.runAgent()` | The agent run loop | `thread.resume(value)` |

## Gate a tool with thread.awaitChoice

`awaitChoice<T>(ui)` posts `ui` and blocks until the user activates a control, resolving to that control's `value`. Because every tool handler receives the live `thread` in its `ChannelToolContext`, you can call it directly inside a `defineChannelTool` handler to gate a destructive action on explicit approval:

```tsx title="tools/confirm-deploy.tsx"
import { Message, Section, Markdown, Actions, Button } from "@copilotkit/channels";
import type { Thread } from "@copilotkit/channels";

async function confirmDeploy(thread: Thread, env: string) {
  const ok = await thread.awaitChoice<boolean>(
    <Message accent="#E01E5A">
      <Section><Markdown>Deploy to **{env}**? This is irreversible.</Markdown></Section>
      <Actions>
        <Button value={true} style="primary">Ship it</Button>
        <Button value={false} style="danger">Cancel</Button>
      </Actions>
    </Message>,
  );
  return ok;
}
```

Inside a tool, the pattern ends with a natural-language result for the agent, not a status object:

```tsx
if (!ok) return "User cancelled; nothing was deployed.";
```

Constraints that apply to any `awaitChoice` tree:

- The clicked `Button`'s `value` is what the promise resolves to, typed as `T`. A `Button` with `url` set becomes a link button and its `value`/`onClick` are ignored, so it cannot resolve a choice.
- The Channels JSX runtime declares an empty `IntrinsicElements` — `<b>`, `<span>`, `<div>` are compile errors. Emphasis belongs inside `<Markdown>`.
- The file must be `.tsx` and the project's tsconfig must set `jsxImportSource: "@copilotkit/channels"`; otherwise the tree compiles against React and fails.
- For a multi-option approval, a `<Select>` with `options: {label, value}[]` works the same way — the selection's `value` resolves the choice (a `string`, or `string[]` when `multi`).

## Agent-originated pauses: onInterrupt + thread.resume

When the agent itself pauses during `thread.runAgent()` — for example a LangGraph interrupt — register a handler for the interrupt event name the agent emits, render a prompt, and re-enter the run with the value the agent expects:

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

```mermaid
sequenceDiagram
    participant Agent as Agent (runAgent loop)
    participant Channel as Channel process
    participant User as Human on the platform

    Agent->>Channel: interrupt event "ask_human" + payload
    Channel->>Channel: onInterrupt handler fires
    Channel->>User: awaitChoice posts approval UI
    User-->>Channel: clicks a Button / picks a Select option
    Channel->>Agent: thread.resume(value)
    Agent-->>Channel: run continues, next MessageRef
```

Behavior details:

- The interrupt handler receives `{ payload, thread, user, actor }`; `payload` is typed by the generic parameter.
- The event name passed to `onInterrupt` must match what the agent emits.
- `thread.resume(value)` re-enters the run loop with `value` and returns the next `MessageRef`, or `undefined`.
- `resume(value, { memory, subject })` accepts the same Intelligence Memory grant shape as `runAgent` (`{ user?, project? }`, each `"none" | "read" | "read-write"`). Omitting the grant disables Memory for the resumed run — there is no implicit access.

## Making approvals survive restarts

Interactive handlers are keyed by **content-stable IDs**: `"ck:" + sha1(name | path | stableStringify(props)).slice(0, 16)`. The same rendered control always produces the same ID, so a click on an old message maps back to the right handler — as long as the binding still exists. The binding lives in the configured store, and that is where durability is decided.

```text
Click on an approval button, after a restart
┌────────────────────────┬───────────────────────────────┬─────────────────────────┐
│ Handler kind           │ Store                         │ Result                  │
├────────────────────────┼───────────────────────────────┼─────────────────────────┤
│ Inline onClick closure │ any                           │ lost — in-process only  │
│ Registered component   │ MemoryStore (default)         │ lost — binding is gone  │
│ Registered component   │ durable StateStore adapter    │ handler re-bound, works │
└────────────────────────┴───────────────────────────────┴─────────────────────────┘
```

Two things are required together:

1. **A durable store.** The default is the in-memory `MemoryStore` — bindings are lost on restart, so a button clicked after a redeploy does not resolve. Implement the `StateStore` interface (persisting to Redis, Postgres, or similar) and pass it as the store adapter.
2. **Registered components.** Pass the component via `createChannel({ components })` (a `defineChannelComponent` component) so its handlers can be re-bound after restart. Without registration, a click on a message posted before the restart degrades to "action expired" even with a durable store.

```ts title="channel.ts"
const channel = createChannel({
  identifyUser: "platform",
  store: {
    adapter: myRedisStore,
    actionRetentionMs: 7 * 24 * 60 * 60 * 1000, // default 7 days
  },
  components: [IssueCard], // register components so handlers can be re-bound
});
```

<ParamField body="store.adapter" type="StateStore">
  A durable implementation of the `StateStore` interface. Replaces the default in-memory `MemoryStore`.
</ParamField>

<ParamField body="store.actionRetentionMs" type="number" default="604800000">
  How long action bindings are retained. Defaults to 7 days.
</ParamField>

<ParamField body="components" type="ChannelComponent[]">
  Components defined with `defineChannelComponent`, registered so their keyed handlers can be recovered after a restart.
</ParamField>

<Warning>
`createChannel({ actionStore })` still works but is deprecated — configure `store.adapter` instead.
</Warning>

<Tip>
Rule of thumb: for a demo or a short-lived prompt, the in-memory default is fine. For approval buttons that must work hours later or across deploys, configure a durable store and use registered components rather than one-off inline closures.
</Tip>

## Common mistakes

- Returning `{ ok: true }` from an approval-gated tool. The return value goes back to the agent — return short natural-language text ("User cancelled; nothing was deployed.") so the model can act on it.
- Using lowercase intrinsic tags in the approval UI. `<b>` and `<span>` do not exist in this JSX runtime; put emphasis inside `<Markdown>`.
- Expecting an inline `onClick` closure to survive a redeploy. Inline handlers route in-process only; durability requires a registered component plus a durable store.
- Configuring a durable `store.adapter` but not registering the component — clicks on pre-restart messages still degrade to "action expired".
- Resuming an interrupted run and expecting Memory access without a grant — `resume(value, { memory })` must state the grant explicitly, same as `runAgent`.

## Related pages

<CardGroup cols={2}>
  <Card title="Add tools" href="/add-tools">
    The `ChannelToolContext` shape that puts the live `thread` inside a tool handler, and return-value rules for approval results.
  </Card>
  <Card title="Render interactive UI" href="/render-interactive-ui">
    The Actions/Button/Select vocabulary used in approval prompts, content-stable IDs, and `defineChannelComponent`.
  </Card>
  <Card title="Thread API reference" href="/thread-api-reference">
    Full signatures for `awaitChoice`, `resume`, `runAgent`, and the rest of the per-conversation thread handle.
  </Card>
  <Card title="createChannel reference" href="/createchannel-reference">
    Every `createChannel` option, including `store` (adapter, actionRetentionMs, concurrency) and `components`.
  </Card>
  <Card title="Handle mentions, messages, and subscriptions" href="/handle-conversations">
    Where `onInterrupt` sits among the ten channel handlers and how turns reach the agent.
  </Card>
</CardGroup>

---

## 12. Slash commands and modals

> Handle commands with channel.onCommand — arguments arrive as raw text (options is populated only on structured surfaces like Discord) — hand them to the agent explicitly, and open modals with ctx.openModal calling Modal({...}) as a function (not <Modal> JSX), routing submissions by callbackId to onModalSubmit/onModalClose.

- Page Markdown: https://grok-wiki.com/public/docs/copilotkit-channels-sdk-4c947e3e6161/pages/12-slash-commands-and-modals.md
- Generated: 2026-08-05T06:40:41.698Z

### Source Files

- `.agents/skills/build-channels-agent/SKILL.md`
- `.agents/skills/build-channels-agent/references/ui-components.md`
- `.agents/skills/build-channels-agent/evals/evals.json`

---
title: "Slash commands and modals"
description: "Handle commands with channel.onCommand — arguments arrive as raw text (options is populated only on structured surfaces like Discord) — hand them to the agent explicitly, and open modals with ctx.openModal calling Modal({...}) as a function (not <Modal> JSX), routing submissions by callbackId to onModalSubmit/onModalClose."
---

`channel.onCommand(name, fn)` registers a slash-command handler on the object `createChannel` returned; the handler receives a `CommandContext` whose argument payload is the raw `text` string after the command name, plus an optional `openModal(view)` trigger. Modals are a separate IR root (`ModalView`), not a message: build the view by calling `Modal({...})` as a plain function, open it with `openModal?.(...)`, and receive submissions and dismissals on `channel.onModalSubmit(callbackId, fn)` / `channel.onModalClose(callbackId, fn)` — routed by `callbackId`, never by inline handlers.

<Note>
Managed slash commands are not part of the managed-Channel product surface today. Commands reach your process through a platform connection you own — for Slack that means the direct adapter over Socket Mode — and adapters expose command registration through the optional `registerCommands` capability.
</Note>

## Handle a command

```tsx title="commands.tsx"
channel.onCommand("top", async ({ thread, text }) => {
  const stories = await fetchTopStories(Number(text) || 5);
  await thread.post(/* a <Message> listing them */);
});
```

Files containing JSX must be `.tsx` and compile with `jsxImportSource: "@copilotkit/channels"`. Handlers must return `void | Promise<void>` — a concise arrow returning `thread.post(...)` fails under `strict` because `post` returns a `MessageRef`; use a block body and `await` the post.

### CommandContext

<ParamField body="text" type="string">
The raw argument string after the command name. This is where arguments arrive on text-only surfaces such as Slack.
</ParamField>

<ParamField body="options" type="object">
The parsed, typed argument form. Populated only by surfaces that deliver structured arguments natively (Discord); empty on text-only surfaces — read `text` there.
</ParamField>

<ParamField body="command" type="string">
The command name that fired.
</ParamField>

<ParamField body="user" type="object">
The invoking user.
</ParamField>

<ParamField body="actor" type="object">
The platform actor for the invocation.
</ParamField>

<ParamField body="platform" type="string">
The originating platform surface.
</ParamField>

<ParamField body="openModal" type="(view: ModalView) => Promise<void>">
Optional. `undefined` on surfaces with no modal trigger — always call it as `openModal?.(...)`.
</ParamField>

<Warning>
There is no `ctx.args` field. Reading slash-command arguments from `args` is a documented failure mode — use `text` (raw) or `options` (typed, structured surfaces only).
</Warning>

### Hand arguments to the agent explicitly

Command arguments are never posted to the channel, so they do not appear in reconstructed conversation history. `thread.runAgent()` with an omitted prompt defaults to the inbound message content — which for a command is not the arguments. Pass them yourself:

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

### Richer command metadata

For a description and an `options` schema registered with the platform, define the command with `defineChannelCommand` and pass it via `createChannel({ commands })`. The identifier is `defineChannelCommand` — `defineBotCommand` does not exist.

## Open a modal

### Call `Modal(...)`, don't write `<Modal>`

The Channels JSX runtime declares `JSX.Element = ChannelNode`, so every JSX expression is typed `ChannelNode` — which erases the `ModalView` narrowing that `openModal` requires. `<Modal …/>` therefore fails under `strict`:

```
error TS2345: Argument of type 'ChannelNode' is not assignable to parameter of type 'ModalView'.
```

Call the component as a plain function and pass `children` as a prop. The children may still be JSX, because only the root must remain a `ModalView`. Do not paper over the error with `as ModalView` or `as any`.

```tsx title="feedback-modal.tsx"
channel.onCommand("feedback", async ({ openModal }) => {
  await openModal?.(
    Modal({
      callbackId: "feedback",
      title: "Send feedback",
      submitLabel: "Send",
      children: [
        <TextInput id="body" label="What happened?" multiline />,
        <RadioButtons id="severity" label="Severity">
          <ModalSelectOption label="Blocking" value="high" />
          <ModalSelectOption label="Annoying" value="low" />
        </RadioButtons>,
      ],
    }),
  );
});

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

### Modal components

All modal components import from the `@copilotkit/channels` root (UI is also available at `/ui`).

| Component | Props | Notes |
| --- | --- | --- |
| `Modal` | `callbackId: string`, `title: string`, `submitLabel?`, `closeLabel?`, `notifyOnClose?`, `privateMetadata?` | The view root. `notifyOnClose` makes Slack emit `view_closed`. `privateMetadata` is an opaque string echoed back to the handlers. |
| `TextInput` | `id: string`, `label: string`, `placeholder?`, `multiline?`, `optional?`, `maxLength?`, `initialValue?` | Free-text field. Read it from `evt.values[id]`. |
| `ModalSelect` | `id: string`, `label: string`, `placeholder?`, `optional?`, `initialOption?` | Children are `ModalSelectOption`. `initialOption` is an option's `value`. |
| `ModalSelectOption` | `label: string`, `value: string` | |
| `RadioButtons` | `id: string`, `label: string`, `optional?`, `initialOption?` | Children are `ModalSelectOption`. |

Unlike message rendering — where an adapter's renderer is total and silently skips unsupported nodes — a modal view that uses an element the surface cannot express makes the adapter throw `ModalRenderError`. Modals do not skip-and-degrade.

## Route submissions by callbackId

Modal results never dispatch to inline handlers. The engine matches the view's `callbackId` against handlers registered up front:

| Handler | Fires when | Handler gets |
| --- | --- | --- |
| `channel.onModalSubmit(callbackId, fn)` | the modal is submitted | `ModalSubmitEvent` — `values` keyed by field `id`, optional `thread`, echoed `privateMetadata` |
| `channel.onModalClose(callbackId, fn)` | the modal is dismissed | `ModalCloseEvent` (Slack requires `notifyOnClose` on the view) |

Return `{ errors }` from a submit handler — an object mapping field `id` to a message — to keep the modal open with inline validation errors. `thread` is optional on `ModalSubmitEvent`: a submission may arrive without a conversation context, so guard with `thread?.` before posting.

```mermaid
sequenceDiagram
    participant U as User
    participant P as Platform surface
    participant C as Your Channel process
    U->>P: /feedback something broke
    P->>C: command event
    C->>C: channel.onCommand("feedback", ctx)
    C->>P: ctx.openModal(Modal({ callbackId: "feedback", ... }))
    U->>P: fills fields, submits
    P->>C: view submission
    C->>C: channel.onModalSubmit("feedback", { values, thread? })
    alt validation fails
        C-->>P: return { errors } — modal stays open
    else success
        C->>P: thread?.post(<Section>…</Section>)
    end
```

## Troubleshooting

<AccordionGroup>
<Accordion title="TS2345: 'ChannelNode' is not assignable to 'ModalView'">
You wrote `<Modal …>` JSX. Call `Modal({ callbackId, title, children: [...] })` as a function instead; only the root needs the `ModalView` type, so the children can stay JSX.
</Accordion>
<Accordion title="openModal is undefined">
`openModal` is optional on `CommandContext` and is `undefined` on surfaces with no modal trigger. Keep the optional call `openModal?.(...)` and design a message-based fallback if the flow must work everywhere.
</Accordion>
<Accordion title="Command arguments are empty">
On text-only surfaces `options` is empty — read `ctx.text`. And `ctx.args` does not exist at all.
</Accordion>
<Accordion title="onModalClose never fires on Slack">
Slack only emits `view_closed` when the view sets `notifyOnClose` on `Modal`.
</Accordion>
<Accordion title="The agent doesn't see the command arguments">
Command args are not posted to the channel, so reconstructed history omits them. Pass them explicitly: `thread.runAgent({ prompt: \`…${text}\` })`.
</Accordion>
</AccordionGroup>

## Related pages

<CardGroup>
<Card title="Handle mentions, messages, and subscriptions" href="/handle-conversations">
The full ten-handler surface onCommand, onModalSubmit, and onModalClose belong to, plus the runAgent defaults.
</Card>
<Card title="UI components reference" href="/ui-components-reference">
Every layout, interactive, and modal component with props and degradation rules.
</Card>
<Card title="Managed Channels vs direct adapters" href="/managed-vs-direct">
Why slash commands ride the direct-adapter path and what the managed surface covers.
</Card>
<Card title="createChannel reference" href="/createchannel-reference">
The `commands` option and the rest of the createChannel configuration.
</Card>
<Card title="Troubleshooting" href="/troubleshooting">
The TS2345 ModalView narrowing error and other documented compile-time failures.
</Card>
</CardGroup>

---

## 13. Author a platform adapter

> Implement the PlatformAdapter contract for a new surface: ingress via start(sink), egress rendering of ChannelNode[] with a total renderer that skips unsupported nodes, createRunRenderer for live agent streaming, decodeInteraction with content-stable ID recovery, and the declared capabilities object.

- Page Markdown: https://grok-wiki.com/public/docs/copilotkit-channels-sdk-4c947e3e6161/pages/13-author-a-platform-adapter.md
- Generated: 2026-08-05T06:40:29.854Z

### Source Files

- `.agents/skills/build-channels-agent/references/adapter-authoring.md`
- `.agents/skills/build-channels-agent/SKILL.md`
- `.agents/skills/build-channels-agent/references/ui-components.md`

---
title: "Author a platform adapter"
description: "Implement the PlatformAdapter contract for a new surface: ingress via start(sink), egress rendering of ChannelNode[] with a total renderer that skips unsupported nodes, createRunRenderer for live agent streaming, decodeInteraction with content-stable ID recovery, and the declared capabilities object."
---

A `PlatformAdapter` is the only platform-specific code in a Channels deployment. It translates between one platform's API and the engine's neutral message IR (`ChannelNode[]`), so Channel logic — handlers, tools, and JSX-rendered UI — runs unchanged across surfaces. Adapters ship on subpaths of the umbrella package (`@copilotkit/channels/slack`, `/teams`, `/discord`, `/telegram`, `/whatsapp`); write a new one only when the task is specifically "add Channels support for platform X" — a surface with no existing adapter. For normal Channel building you consume an adapter, you never implement one.

<Note>
The engine, the Channel handlers, and the JSX vocabulary do not change when a new adapter is added. Everything an adapter does is bounded by this contract; the rest of the SDK treats it as a black box behind the IR.
</Note>

## Contract overview

```mermaid
classDiagram
    class PlatformAdapter {
        <<contract>>
        +start(sink: IngressSink)
        +post(target, nodes: ChannelNode[]) MessageRef
        +update(ref: MessageRef, nodes: ChannelNode[])
        +stream(target, src)
        +delete(ref: MessageRef)
        +createRunRenderer(target)
        +decodeInteraction(raw)
        +lookupUser(id)
        +conversationStore
        +capabilities
    }
    class IngressSink {
        <<engine-provided>>
        turns : mentions and messages
        interactions : clicks, selects, input submits
        commands : slash commands
        threadStarted : surface opened
    }
    class Capabilities {
        <<feature flags>>
        getMessages?
        postFile?
        setSuggestedPrompts?
        setThreadTitle?
        registerCommands?
    }
    class SlackAdapter {
        <<@copilotkit/channels-slack>>
        Block Kit rendering
        interaction payload decoding
        socket-mode ingress
    }
    PlatformAdapter --> IngressSink : reports inbound events
    PlatformAdapter --> Capabilities : declares
    SlackAdapter ..|> PlatformAdapter : reference implementation
```

`@copilotkit/channels-slack` is the canonical, complete adapter. Read its source before writing a new one — it shows the full ingress/egress/decode/capabilities wiring against a real platform.

## Ingress — start(sink)

`start(sink: IngressSink)` receives a sink your adapter calls to report inbound platform activity to the engine. Decode raw platform payloads into the engine's shapes before handing them to the sink. Four event classes flow through it:

| Event class | What it carries | Engine routes it to |
| --- | --- | --- |
| Turns | Inbound mentions and messages | `onMention` / `onMessage` handlers |
| Interactions | Button clicks, select and input submissions | Bound `onClick`/`onSelect` handlers, `onInteraction` |
| Commands | Slash commands | `channel.onCommand(name, fn)` |
| Thread-started | A conversation surface opens | `channel.onThreadStarted(fn)` |

## Egress — rendering ChannelNode[]

Given `ChannelNode[]` — the lowered JSX tree — the adapter renders to the platform through four operations:

<ParamField body="post(target, nodes)" type="method" required>
Create a message from the IR tree. Returns a `MessageRef` the engine uses for later `update`/`delete`/`react` calls.
</ParamField>

<ParamField body="update(ref, nodes)" type="method" required>
Edit an existing message identified by its `MessageRef`, replacing its content with a newly rendered tree.
</ParamField>

<ParamField body="stream(target, src)" type="method" required>
Stream tokens — typically by progressively editing a message as text arrives.
</ParamField>

<ParamField body="delete(ref)" type="method" required>
Remove a previously posted message.
</ParamField>

### The renderer must be total

Map each IR node type (`message`, `section`, `actions`, `button`, `select`, `table`, `chart`, …) to the platform's native construct — Block Kit on Slack, Adaptive Cards on Teams, components on Discord. **Skip node types the surface cannot express; never throw on an unsupported node.** This totality is what makes cross-platform degradation work: the same JSX tree that renders a chart on one surface silently drops it on another instead of erroring. Existing adapters follow concrete degradation rules — for example, a multi-select degrades to single-select on Telegram and WhatsApp, and surfaces without field labels fall back to the field's value text alone.

<Warning>
Modals are the one exception to skip-and-degrade. A modal is a separate IR root (`ModalView`), not a message, and an adapter throws `ModalRenderError` when a modal view uses an element its surface can't express. Message rendering degrades; modal rendering fails loudly.
</Warning>

## Agent streaming — createRunRenderer

`createRunRenderer(target)` returns a renderer the engine drives while an agent run streams, so intermediate steps — tokens, tool-call progress — show up live in the conversation. This is what backs `thread.runAgent()`'s step-by-step rendering on your surface.

## Decoding and lookup

<ParamField body="decodeInteraction(raw)" type="method" required>
Turn a raw platform interaction payload into the engine's interaction shape. It **must recover the content-stable action ID** embedded when the control was rendered.
</ParamField>

<ParamField body="lookupUser(id)" type="method">
Resolve a platform user to the engine's user shape. Backs `thread.lookupUser(query)`; where unsupported, the thread method degrades to `undefined` instead of throwing.
</ParamField>

<ParamField body="conversationStore" type="property">
Persist and restore conversation identity for the platform.
</ParamField>

### Content-stable ID recovery

Interactive handlers are keyed by content-stable IDs computed as:

```text
"ck:" + sha1(name | path | stableStringify(props)).slice(0, 16)
```

The same rendered control always produces the same ID, so a click maps back to the right handler — including on messages posted long before the click, and across restarts when the Channel uses a durable store with registered components. Your adapter's job on both sides of the round-trip:

1. **Egress** — carry the action ID into whatever the platform uses for interactive-control identity (e.g. Block Kit `action_id`), without mangling it.
2. **Ingress** — in `decodeInteraction`, extract that exact ID from the raw payload so the engine can resolve the binding.

If the ID does not survive the encode → platform → decode path byte-for-byte, every button and select on your surface routes nowhere.

## Capabilities

Declare a `capabilities` object so the engine and Channel code can feature-detect the surface. Implement the optional capability methods only when the platform supports them:

| Capability | Backs | Degradation when absent |
| --- | --- | --- |
| `getMessages` | `thread.getMessages()` — read conversation history | Returns `[]` |
| `postFile` | `thread.postFile({ ... })` — upload files | Capability-gated on the thread |
| `setSuggestedPrompts` | `thread.setSuggestedPrompts(...)` — suggested follow-ups | Capability-gated on the thread |
| `setThreadTitle` | `thread.setTitle(title)` — rename the surface | Capability-gated on the thread |
| `registerCommands` | Registering slash commands with the platform | Commands not registered natively |

Capability-gated thread methods degrade rather than throw, so Channel code written against a full-featured adapter still runs on a minimal one.

## Test the adapter

Because the engine is platform-agnostic, exercise a new adapter with the same Channel you would run on Slack — only the `adapters` entry changes:

```ts
import { createChannel } from "@copilotkit/channels";
import { myPlatform } from "./my-platform-adapter.js";

const channel = createChannel({
  identifyUser: "platform",
  adapters: [myPlatform({ /* platform credentials */ })],
  agent: makeAgent,
});
```

Verify three things:

<Steps>
  <Step title="Every IR node type renders or degrades">
    Post trees covering each node type — `message`, `section`, `actions`, `button`, `select`, `table`, `chart` — and confirm each renders natively or is skipped without throwing.
  </Step>
  <Step title="Interactions round-trip">
    Click every interactive control and confirm the event reaches its bound handler through `start(sink)` and `decodeInteraction`.
  </Step>
  <Step title="Content-stable IDs survive the decode path">
    Confirm the action ID recovered by `decodeInteraction` matches the ID generated at render time, including for messages posted before a process restart when a durable store is configured.
  </Step>
</Steps>

`name` is optional on `createChannel` in the types precisely because purely local, custom-adapter Channels omit it — you can exercise an adapter without a managed Channel Code. The direct-adapter path still requires the CopilotKit Intelligence runtime to own the Channel lifecycle; there is no `channel.start()`.

## Related pages

<CardGroup cols={2}>
  <Card title="Render interactive UI" href="/render-interactive-ui">
    The JSX trees your adapter receives as ChannelNode[], inline handlers, and how degradation looks from the Channel author's side.
  </Card>
  <Card title="Managed Channels vs direct adapters" href="/managed-vs-direct">
    Where adapters plug in via createChannel({ adapters }), and when the direct path applies at all.
  </Card>
  <Card title="Thread API reference" href="/thread-api-reference">
    The capability-gated thread methods your capabilities object enables or degrades.
  </Card>
  <Card title="UI components reference" href="/ui-components-reference">
    Every component that lowers to an IR node, with per-platform degradation rules to match in your renderer.
  </Card>
</CardGroup>

---

## 14. createChannel reference

> Every createChannel option with constraints: required identifyUser ("platform" or a callback), Channel Code naming rules for name, the agent factory contract and per-turn cloning, adapters, tools, context, components, commands, store (adapter, state schema, actionRetentionMs, concurrency), showToolStatus, replyContinuation, and sanitizeAgentEvents.

- Page Markdown: https://grok-wiki.com/public/docs/copilotkit-channels-sdk-4c947e3e6161/pages/14-createchannel-reference.md
- Generated: 2026-08-05T06:41:38.101Z

### Source Files

- `.agents/skills/build-channels-agent/SKILL.md`
- `.agents/skills/build-channels-agent/references/hitl-patterns.md`
- `examples/minimal-channel/lib/channel.ts`
- `.agents/skills/build-channels-agent/evals/evals.json`

---
title: "createChannel reference"
description: "Every createChannel option with constraints: required identifyUser (\"platform\" or a callback), Channel Code naming rules for name, the agent factory contract and per-turn cloning, adapters, tools, context, components, commands, store (adapter, state schema, actionRetentionMs, concurrency), showToolStatus, replyContinuation, and sanitizeAgentEvents."
---

`createChannel(options)` is the entry point of `@copilotkit/channels`. It returns a `Channel` object that you attach handlers to (`onMention`, `onMessage`, `onCommand`, …) and then hand to `CopilotRuntime({ channels: [channel] })`. The function itself does not start anything — there is no `channel.start()`; creating the runtime listener with `createCopilotNodeListener` is what starts the Channel. The factory is named `createChannel`; `createBot` and other `Bot`-prefixed names come from a pre-release and exist nowhere in the shipped packages.

```ts
import { createChannel } from "@copilotkit/channels";
import { makeAgent } from "./agent.js";

const channel = createChannel({
  name: process.env.CHANNEL_CODE!, // must equal the Channel Code in Intelligence
  identifyUser: "platform",        // required
  agent: makeAgent,                // factory: (threadId) => agent
});
```

<Note>
Every API on this page exists in both `@copilotkit/channels@0.6.1` + `@copilotkit/runtime@1.65.0` and the `0.7.1` + `1.66.1` pair, except `defineChannelComponent`, which is 0.7+ only. Channels and Runtime ship as a version-locked pair — upgrade them together.
</Note>

## Options summary

| Option | Type | Required | Purpose |
| --- | --- | --- | --- |
| `identifyUser` | `"platform"` \| callback | Yes | Resolve the canonical user per event |
| `name` | `string` | Managed Channels | Must equal the Intelligence Channel Code |
| `agent` | factory or agent instance | Yes (to run an agent) | AG-UI agent supplied per thread |
| `adapters` | `PlatformAdapter[]` | No | Direct platform connections you own |
| `tools` | `ChannelTool[]` | No | Typed tools the agent can call |
| `context` | `ContextEntry[]` | No | Prompt context injected per run |
| `components` | registered components | No | Re-bind agent-rendered UI handlers after restart |
| `commands` | `ChannelCommand[]` | No | Slash-command metadata (`defineChannelCommand`) |
| `store` | `{ adapter, state, actionRetentionMs, concurrency, … }` | No | Persistence, per-thread state schema, turn concurrency |
| `showToolStatus` | `boolean` | No | Live tool-call progress on managed Slack |
| `replyContinuation` | `{ messageByteLimit, maxMessages, truncationMarker }` | No | Long-reply splitting and truncation |
| `sanitizeAgentEvents` | option | No | Adjust the agent event stream before rendering |

## identifyUser

<ParamField body="identifyUser" type='"platform" | (ctx) => ApplicationUser | null' required>
Required on every `createChannel` call. `"platform"` derives the canonical user from provider + workspace + platform user id and is the right default. Pass a callback to map platform identities onto your own user table; it returns `ApplicationUser | null`.
</ParamField>

<Warning>
Do not confuse this with `CopilotRuntime({ identifyUser })`. The runtime option resolves users for *web* requests and must be absent on a Channels-only runtime. Omitting `identifyUser` on `createChannel` is a documented common mistake.
</Warning>

Per-user Intelligence Memory (`thread.runAgent({ memory })`) only means anything when `identifyUser` resolves a user.

## name — Channel Code rules

<ParamField body="name" type="string">
For a managed Channel, `name` must be the exact Channel Code from Intelligence, typically read from `process.env.CHANNEL_CODE`.
</ParamField>

Channel Code constraints:

- 3–64 characters.
- Starts with a lowercase letter.
- Lowercase letters and digits, separated by single hyphens.
- Unique within the Intelligence project.
- Never the literal string `channels`.

Validation happens in the **runtime at startup**, not inside `createChannel` — a typo fails when the listener starts, not at the call site, and a mismatch leaves the Channel stuck at **Waiting for runtime** in the dashboard. `name` is optional in the types only because purely local / custom-adapter Channels omit it.

## agent — factory contract and per-turn cloning

<ParamField body="agent" type="(threadId: string) => AbstractAgent | AbstractAgent">
Accepts a factory `(threadId) => agent` or a single agent instance. Prefer the factory and return a fresh agent per `threadId`; never share one stateful instance across conversations.
</ParamField>

```ts
import { BuiltInAgent } from "@copilotkit/runtime/v2";

export function makeAgent(threadId: string) {
  const agent = new BuiltInAgent({ model: "openai:gpt-5.4-mini" });
  agent.threadId = threadId;
  return agent;
}
```

The built-in agent runs in the same Node process — no `AGENT_URL`, no second server. For a remote AG-UI agent, use `HttpAgent` from `@ag-ui/client` (also re-exported from `@copilotkit/channels`) pointed at the agent's URL, as `examples/minimal-channel/lib/channel.ts` does.

Cloning and concurrency behavior:

- Turn concurrency defaults to `"parallel"`.
- You do not hand-manage isolation: Channels **clones the agent per turn** for every configured shape — a singleton instance, a fresh-per-call factory, and a factory that returns the same object.
- What cloning cannot fix is a broken `clone()`. A custom `AbstractAgent` subclass with no `clone()`, or one that drops subclass state, fails loudly at turn start.

<Warning>
If passing *any* agent fails to compile with "Types have separate declarations of a private property `_debug`", two copies of `@ag-ui/client` are installed. Pin one with `{ "overrides": { "@ag-ui/client": "0.0.57" } }` (use the version `@copilotkit/runtime` declares — `npm ls @ag-ui/client` shows both) and reinstall. See the installation page.
</Warning>

## adapters — direct platform connections

<ParamField body="adapters" type="PlatformAdapter[]">
Pass adapters only when *you* hold the platform tokens. One Channel can run several platforms at once. The managed default has no adapter at all — Intelligence holds the credentials and you add platforms in the dashboard.
</ParamField>

```ts
import { slack, defaultSlackTools, defaultSlackContext } from "@copilotkit/channels/slack";

const channel = createChannel({
  name: "support-slack",
  identifyUser: "platform",
  adapters: [
    slack({
      botToken: process.env.SLACK_BOT_TOKEN!, // xoxb-…
      appToken: process.env.SLACK_APP_TOKEN!, // xapp-… (Socket Mode)
    }),
  ],
  agent: makeAgent,
  tools: [...defaultSlackTools],
  context: [...defaultSlackContext],
});
```

Constraints:

- A direct adapter does **not** remove the Intelligence requirement — the runtime still owns the lifecycle.
- Socket Mode and the `xapp-` token belong only to this path; a managed Channel uses signed HTTPS ingress plus an outbound gateway socket and needs no app token.
- Switching to a direct adapter to escape a `setup_required` managed Channel is a known failure mode, not a fallback — fix the managed setup instead.
- There is no `provider: "slack"` option on `createChannel`. The platform comes from the adapter, or from Intelligence for a managed Channel.

Adapters live on subpaths: `@copilotkit/channels/slack`, `/teams`, `/discord`, `/telegram`, `/whatsapp`.

## tools

<ParamField body="tools" type="ChannelTool[]">
Typed tools the agent can call, built with `defineChannelTool`. Parameters accept any Standard Schema validator (Zod, Valibot, ArkType). Also registrable after construction with `channel.tool(t)` before the runtime starts, or per run via `thread.runAgent({ tools })`.
</ParamField>

Tool handlers receive the parsed args plus `ChannelToolContext` = `{ thread, message?, user, actor, signal?, platform }`, where `user` is `ApplicationUser | null`. The return value goes back to the **agent**, not the user: return raw data (it is JSON-stringified for you), return the actual error text on failure, and for a tool that posts a card return a short confirmation like `"Displayed the issue card."`.

## context

<ParamField body="context" type="ContextEntry[]">
`{ description: string; value: string }` pairs injected into the agent's prompt on every run — the channel, the caller's role, anything that grounds the turn. Per-run context goes through `thread.runAgent({ context })` instead. Direct Slack Channels should include `defaultSlackContext`.
</ParamField>

## components

<ParamField body="components" type="ChannelComponent[]">
Registers components created with `defineChannelComponent` (0.7+) so the agent can call them as render tools, and so their keyed interaction handlers can be **re-bound after a restart** when the store is durable.
</ParamField>

Interactive handlers are keyed by content-stable IDs (`"ck:" + sha1(name | path | stableStringify(props)).slice(0, 16)`), so the same rendered control always maps back to the same handler. Durability requires both a durable `store.adapter` *and* registration here — without registration, a click on a message posted before the restart degrades to "action expired".

## commands

<ParamField body="commands" type="ChannelCommand[]">
Slash commands with richer metadata — a description and an `options` schema registered with the platform — built with `defineChannelCommand`. Handlers attach with `channel.onCommand(name, fn)`; arguments arrive on `CommandContext` as raw `text`, with `options` populated only on structured surfaces like Discord.
</ParamField>

## store

<ParamField body="store" type="object">
Persistence and per-thread behavior: the state store adapter, the per-thread state schema, transcripts, action retention, and turn `concurrency` (default `"parallel"`).
</ParamField>

```ts
const channel = createChannel({
  identifyUser: "platform",
  store: {
    adapter: myRedisStore,                      // StateStore implementation
    actionRetentionMs: 7 * 24 * 60 * 60 * 1000, // default 7 days
  },
  components: [IssueCard], // required for handler re-binding
});
```

<ResponseField name="store.adapter" type="StateStore">
Where interaction bindings, subscriptions, and per-thread state live. Default is the in-memory `MemoryStore` — ephemeral, so bindings are lost on restart and a button clicked after a redeploy won't resolve. Implement the `StateStore` interface (Redis, Postgres, …) for buttons that must work hours later or across deploys.
</ResponseField>

<ResponseField name="store.state" type="schema">
The per-thread state schema that types `thread.state<T>()` / `thread.setState(v)`.
</ResponseField>

<ResponseField name="store.actionRetentionMs" type="number" default="604800000">
How long interaction bindings are retained. Default 7 days.
</ResponseField>

<ResponseField name="store.concurrency" type="string" default='"parallel"'>
Turn concurrency. The per-turn agent clone means parallel turns do not share mutable agent state.
</ResponseField>

<Warning>
`createChannel({ actionStore })` still works but is **deprecated** — use `store.adapter`.
</Warning>

## showToolStatus

<ParamField body="showToolStatus" type="boolean">
Managed Slack hides tool-call progress by default so the conversation ends with a clean result; the lifecycle events still land in Intelligence history and are available on replay. Set `showToolStatus: true` to opt into the live timeline per Channel.
</ParamField>

This option is **ignored for direct-adapter Channels** — configure those on the adapter instead: `slack({ showToolStatus: true })`. Other managed providers keep their own default when it is unset.

## replyContinuation

<ParamField body="replyContinuation" type="{ messageByteLimit, maxMessages, truncationMarker }">
Providers cap how much text one message holds. Past the per-message limit the reply is split across continuation messages; past the ceiling it is truncated with a visible marker. Honoured by managed and direct Slack today.
</ParamField>

## sanitizeAgentEvents

<ParamField body="sanitizeAgentEvents" type="option">
Adjusts the agent's event stream before it is surfaced into the conversation. Listed by the SDK alongside `showToolStatus` and `replyContinuation` as an output-shaping option; no further constraints are documented in this repository.
</ParamField>

## What createChannel does not accept

- No `provider` option — the platform comes from adapters or Intelligence.
- No lifecycle methods on the return value — `channel.start()` / `channel.stop()` do not exist; attach the Channel to `CopilotRuntime` and create a listener, and stop via `listener.channels.stop()`.
- No `Bot`-prefixed API — `createBot`, `defineBotTool`, `defineBotCommand`, and `BotToolContext` do not compile.
- No generic event emitter — use the named handlers (`onMention`, `onMessage`, `onCommand`, `onInteraction`, `onInterrupt`, `onReaction`, `onModalSubmit`, `onModalClose`, `onThreadStarted`, `onWelcome`), attached before the runtime starts the Channel.

## Related pages

<CardGroup cols={2}>
  <Card title="Managed Channels vs direct adapters" href="/managed-vs-direct">
    When to pass `adapters` versus letting Intelligence hold the platform credentials.
  </Card>
  <Card title="Add tools" href="/add-tools">
    `defineChannelTool`, Standard Schema validators, and the `ChannelToolContext` shape.
  </Card>
  <Card title="Channel lifecycle and status" href="/channel-lifecycle">
    How the runtime starts the Channel, `ready()` semantics, and the six status values.
  </Card>
  <Card title="Human-in-the-loop approvals" href="/human-in-the-loop">
    Why `store.adapter` plus registered `components` make approval buttons survive restarts.
  </Card>
  <Card title="Handle mentions, messages, and subscriptions" href="/handle-conversations">
    The ten channel handlers attached to the object `createChannel` returns.
  </Card>
  <Card title="Configuration reference" href="/configuration-reference">
    `CHANNEL_CODE`, `INTELLIGENCE_API_KEY`, and the paired URL overrides.
  </Card>
</CardGroup>

---

## 15. Thread API reference

> The per-conversation thread handle: post, update, delete, stream, postFile, postEphemeral, runAgent (prompt, context, tools, transcript, memory grants), resume, awaitChoice, subscribe/unsubscribe/isSubscribed, getMessages, setTitle, setSuggestedPrompts, react, state/setState, and lookupUser — including which methods are capability-gated and degrade instead of throwing.

- Page Markdown: https://grok-wiki.com/public/docs/copilotkit-channels-sdk-4c947e3e6161/pages/15-thread-api-reference.md
- Generated: 2026-08-05T06:41:04.968Z

### Source Files

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

---
title: "Thread API reference"
description: "The per-conversation thread handle: post, update, delete, stream, postFile, postEphemeral, runAgent (prompt, context, tools, transcript, memory grants), resume, awaitChoice, subscribe/unsubscribe/isSubscribed, getMessages, setTitle, setSuggestedPrompts, react, state/setState, and lookupUser — including which methods are capability-gated and degrade instead of throwing."
---

Every channel handler — `onMention`, `onMessage`, `onCommand`, `onInterrupt`, tool handlers, inline `onClick`/`onSelect` handlers — receives a `thread`: the per-conversation handle for `@copilotkit/channels`. All rendering, agent runs, human-in-the-loop pauses, and per-conversation state go through it. The same `Thread` methods work on every platform an adapter supports; where a surface cannot express an operation, the capability-gated methods degrade to an empty or `undefined` result rather than throwing.

<Note>
`onMention` and `onMessage` handlers receive `{ thread, message }` only — there is no `user` on those contexts. Reach the caller through the message, or use a context that exposes `user` (`onThreadStarted`, `onWelcome`, `ChannelToolContext`).
</Note>

## Method inventory

| Method | Purpose | Returns |
| --- | --- | --- |
| `thread.post(ui)` | Render a JSX message | `MessageRef` |
| `thread.update(ref, ui)` | Replace a previously posted message | — |
| `thread.delete(ref)` | Remove a message | — |
| `thread.stream(src)` | Stream a `string` / `AsyncIterable<string>` live | — |
| `thread.postFile({ ... })` | Upload a file | — |
| `thread.postEphemeral(user, ui, opts)` | Message visible to one user only; `opts` (with `fallbackToDM`) is required | — |
| `thread.runAgent(input?)` | Run the agent's run / tool-call / interrupt loop | — |
| `thread.resume(value, opts?)` | Re-enter the run loop after an interrupt | `MessageRef \| undefined` |
| `thread.awaitChoice<T>(ui)` | Post a picker and block until the user chooses | `T` |
| `thread.subscribe()` / `unsubscribe()` / `isSubscribed()` | Persisted per-conversation flag | `boolean` from `isSubscribed()` |
| `thread.getMessages()` | Read the conversation history | messages, or `[]` (capability-gated) |
| `thread.setTitle(title)` | Rename the conversation surface | — |
| `thread.setSuggestedPrompts(...)` | Suggest follow-up prompts | — |
| `thread.react(ref, emoji)` / `unreact(ref, emoji)` | Add or remove an emoji reaction on a message | — |
| `thread.state<T>()` / `setState(v)` | Per-thread state, typed by the `store.state` schema | `T` from `state()` |
| `thread.lookupUser(query)` | Resolve a platform user | user, or `undefined` (capability-gated) |

<Warning>
Handlers must return `void | Promise<void>`, and `post` returns a `MessageRef`. A concise arrow like `({ thread }) => thread.post(...)` fails under `strict`. Use a block body: `async ({ thread }) => { await thread.post(...); }`.
</Warning>

## Posting and rendering

`post`, `update`, `awaitChoice`, and `postEphemeral` take a JSX tree built from the `@copilotkit/channels` components (`Message`, `Header`, `Section`, `Markdown`, `Actions`, `Button`, …). The engine lowers the tree to a platform-neutral IR; each adapter renders what its surface supports and skips nodes it cannot express, so a rich tree degrades gracefully instead of erroring.

```tsx
const ref = await thread.post(
  <Message accent="#27AE60">
    <Header>Deploy status</Header>
    <Section><Markdown>**staging** is green.</Markdown></Section>
  </Message>,
);

await thread.update(ref, <Section>Superseded — see the new report below.</Section>);
await thread.delete(ref);
```

Inline `onClick`/`onSelect` handlers receive `{ action, thread, messageRef, user }`, so a button can rewrite the message it lives in:

```tsx
<Button value="approve" style="primary"
  onClick={async ({ thread, messageRef }) => {
    await thread.update(messageRef, <Section>Approved ✓</Section>);
  }}>
  Approve
</Button>
```

`thread.stream(src)` accepts a plain string or an `AsyncIterable<string>` and progressively edits a live message as tokens arrive. `thread.postFile({ ... })` uploads a file where the adapter implements the `postFile` capability. `thread.postEphemeral(user, ui, { fallbackToDM })` targets a single user; the options argument is required.

## runAgent

`thread.runAgent(input?)` drives the agent's run / tool-call / interrupt loop and renders each step as it streams. Prefer it over hand-managing agent events. Called with no arguments, `prompt` defaults to the inbound `message.contentParts` or `message.text`, so plain `runAgent()` is the correct mention reply:

```ts
channel.onMention(async ({ thread }) => {
  await thread.runAgent();
});
```

Pass `prompt` explicitly only when the input is not in reconstructed history — slash-command arguments, or when combining text and attachments yourself:

```ts
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 }],
  });
});
```

<ParamField body="prompt" type="string | AgentContentPart[]">
The user turn for this run. Defaults to the inbound message's `contentParts` or `text` when omitted. Slash-command arguments are never posted to the channel, so they are not in reconstructed history — pass them explicitly (`runAgent({ prompt: \`Triage: ${text}\` })`).
</ParamField>

<ParamField body="context" type="ContextEntry[]">
`{ description: string; value: string }` pairs injected into the agent's prompt for this run only. Channel-wide context goes on `createChannel({ context })` instead.
</ParamField>

<ParamField body="tools" type="ChannelTool[]">
Extra tools available for this run, in addition to those registered via `createChannel({ tools })` or `channel.tool()`.
</ParamField>

<ParamField body="transcript" type="boolean">
Auto-bridges cross-platform transcripts: injects history, appends the user turn, runs, appends the reply. The flag owns the whole bridge — do not also append the same turns via `channel.transcripts.append`. It no-ops with a warning when identity or transcripts are not configured.
</ParamField>

<ParamField body="memory" type="{ user?: Grant; project?: Grant }">
Intelligence Memory grant for this run only, where each grant is `"none" | "read" | "read-write"` (e.g. `{ user: "read-write", project: "read" }`). Omitting `memory` disables Memory entirely — there is no implicit access.
</ParamField>

## resume

`thread.resume(value, opts?)` re-enters the run loop after the agent paused itself mid-run (a LangGraph-style interrupt surfaced through `channel.onInterrupt`). It returns the next `MessageRef`, or `undefined`. The options take the same `memory` grant shape as `runAgent`, plus `subject`:

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

## awaitChoice

`thread.awaitChoice<T>(ui)` posts the JSX tree and blocks the handler until the user activates a control, resolving to that control's `value` typed as `T`. Because tool handlers receive the live `thread`, calling it inside a `defineChannelTool` handler gates a destructive tool on human approval:

```tsx
const ok = await thread.awaitChoice<boolean>(
  <Message accent="#E01E5A">
    <Section><Markdown>Deploy to **production**? This is irreversible.</Markdown></Section>
    <Actions>
      <Button value={true} style="primary">Ship it</Button>
      <Button value={false} style="danger">Cancel</Button>
    </Actions>
  </Message>,
);
if (!ok) return "User cancelled; nothing was deployed.";
```

Whether the buttons still resolve after a process restart depends on the configured store: the default in-memory `MemoryStore` loses bindings on restart, while a durable `StateStore` adapter plus registered components (`createChannel({ store: { adapter }, components })`) lets clicks survive redeploys.

## Subscriptions

`subscribe()`, `unsubscribe()`, and `isSubscribed()` manage a persisted per-conversation flag. The standard pattern answers every message in a conversation the agent was invited into, rather than only mentions — this is exactly what `examples/minimal-channel/lib/channel.ts` ships:

```ts
channel.onMention(async ({ thread }) => {
  await thread.subscribe();
  await thread.runAgent();
});

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

## Conversation surface and state

- `thread.getMessages()` reads the conversation history where the adapter implements it.
- `thread.setTitle(title)` renames the conversation surface (for example a Slack assistant pane).
- `thread.setSuggestedPrompts(...)` offers follow-up prompts on surfaces that support them.
- `thread.react(ref, emoji)` / `thread.unreact(ref, emoji)` add or remove an emoji reaction on a posted message. Inbound reactions arrive via `channel.onReaction` or a `<Message onReaction>` prop.
- `thread.state<T>()` / `thread.setState(v)` read and write per-thread state. The type is derived from the `state` schema configured under `createChannel({ store })`.
- `thread.lookupUser(query)` resolves a platform user through the adapter's `lookupUser` implementation.

## Capability gating and degradation

Not every platform can express every operation. Adapters declare a `capabilities` object, and the optional adapter methods behind it include `getMessages`, `postFile`, `setSuggestedPrompts`, and `setThreadTitle`. Capability-gated `Thread` methods degrade rather than throw:

| Method | Where the adapter lacks the capability |
| --- | --- |
| `thread.getMessages()` | returns `[]` |
| `thread.lookupUser(query)` | returns `undefined` |

Rendering follows the same philosophy: an adapter's message renderer is total and skips IR nodes its surface cannot express, so `post`/`update` never throw on an unsupported component. Modals are the exception to skip-and-degrade — an unrenderable `ModalView` throws `ModalRenderError` — but modals open through `ctx.openModal` on `CommandContext`, not through the thread handle.

For direct Slack, `defaultSlackTools` from `@copilotkit/channels/slack` also exposes user resolution to the agent as the `lookup_slack_user` tool.

## Constraints

- Await every thread call. Handlers must resolve to `void`; returning a `MessageRef` from a concise arrow fails under `strict`.
- JSX trees passed to `post`/`update`/`awaitChoice`/`postEphemeral` must come from `@copilotkit/channels` components in a `.tsx` file compiled with `jsxImportSource: "@copilotkit/channels"`. There are no lowercase intrinsic tags — `<b>`, `<div>`, `<span>` are compile errors; emphasis goes inside `<Markdown>`.
- Do not set `runAgent({ transcript: true })` and also append the same turns via `channel.transcripts.append` — the flag owns the bridge.
- Do not expect Intelligence Memory without an explicit `memory` grant on `runAgent` or `resume` — omission disables it.
- Prefer `thread.runAgent()` over manually looping over agent events.

## Related pages

<CardGroup cols={2}>
  <Card title="Handle mentions, messages, and subscriptions" href="/handle-conversations">
    The ten channel handlers that hand you a thread, and the subscribe pattern in context.
  </Card>
  <Card title="Human-in-the-loop approvals" href="/human-in-the-loop">
    awaitChoice, onInterrupt + resume, and making approval buttons survive restarts.
  </Card>
  <Card title="Render interactive UI" href="/render-interactive-ui">
    The JSX trees you pass to post, update, and awaitChoice, and how they degrade per surface.
  </Card>
  <Card title="createChannel reference" href="/createchannel-reference">
    Channel-level options that shape thread behavior: store, state schema, tools, context, components.
  </Card>
  <Card title="Add tools" href="/add-tools">
    ChannelToolContext and how tool handlers use the live thread mid-run.
  </Card>
  <Card title="UI components reference" href="/ui-components-reference">
    Full component vocabulary and the handler context shapes for onClick, onSelect, and onSubmit.
  </Card>
</CardGroup>

---

## 16. UI components reference

> The full channels-ui JSX vocabulary with props and degradation rules: layout components (Message, Header, Section, Markdown, Fields, Field, Context, Divider, Image, Table, Chart), interactive components (Actions, Button, Select, Input), modal components (Modal, TextInput, ModalSelect, RadioButtons), and handler context shapes.

- Page Markdown: https://grok-wiki.com/public/docs/copilotkit-channels-sdk-4c947e3e6161/pages/16-ui-components-reference.md
- Generated: 2026-08-05T06:40:22.117Z

### Source Files

- `.agents/skills/build-channels-agent/references/ui-components.md`
- `.agents/skills/build-channels-agent/SKILL.md`
- `.agents/skills/build-channels-agent/references/hitl-patterns.md`

---
title: "UI components reference"
description: "The full channels-ui JSX vocabulary with props and degradation rules: layout components (Message, Header, Section, Markdown, Fields, Field, Context, Divider, Image, Table, Chart), interactive components (Actions, Button, Select, Input), modal components (Modal, TextInput, ModalSelect, RadioButtons), and handler context shapes."
---

All channels-ui components import from `@copilotkit/channels` — the root import re-exports the full vocabulary, and `@copilotkit/channels/ui` is the same surface. A message is a JSX tree passed to `thread.post`, `thread.update`, or `thread.awaitChoice`; the engine lowers it to a platform-neutral IR (`ChannelNode[]`), and each adapter renders the nodes its surface supports and **skips the rest**. The message renderer is total, so a rich tree degrades gracefully instead of throwing. Modals are the one exception: they are a separate IR root (`ModalView`) and an adapter throws `ModalRenderError` on an element its surface cannot express.

<Warning>
Only import from `@copilotkit/channels-ui` directly if you installed that package as a direct dependency. It is a transitive dependency of the umbrella package, so the import resolves under npm's hoisted layout but fails under pnpm's isolated one.
</Warning>

## Rendering model

```mermaid
flowchart LR
    subgraph app["Your Channel process"]
        JSX["JSX tree<br/>(Message, Section, Button, …)"]
        MODAL["Modal({...}) call<br/>(ModalView root)"]
    end
    subgraph engine["Channels engine"]
        IR["ChannelNode[] IR"]
        MV["ModalView IR"]
    end
    subgraph adapters["Platform adapters"]
        SLACK["Slack → Block Kit"]
        TEAMS["Teams → Adaptive Cards"]
        DISCORD["Discord → components"]
    end
    JSX --> IR
    MODAL --> MV
    IR -->|"render supported nodes,<br/>skip unsupported (total renderer)"| SLACK
    IR --> TEAMS
    IR --> DISCORD
    MV -->|"unsupported element →<br/>throws ModalRenderError"| SLACK
```

Rules the runtime enforces:

- Children may be nested elements, strings, numbers, or conditionals — `false`, `null`, and `undefined` render nothing — plus arrays of any of those.
- There are **no lowercase intrinsic tags**. This JSX runtime declares an empty `IntrinsicElements`, so `<b>`, `<span>`, and `<div>` are compile errors. Emphasis goes inside `<Markdown>`.
- Files containing JSX must be `.tsx`, compiled with `"jsx": "react-jsx"` and `"jsxImportSource": "@copilotkit/channels"`. Without the import source the tree compiles against React and fails.
- Do not invent tag names or props beyond this reference — a made-up tag will not lower to a valid IR node.

## Layout and content components

| Component | Props | Notes |
| --- | --- | --- |
| `<Message>` | `accent?: string`, `onReaction?` | Top-level wrapper. `accent` is a hex color (e.g. `#27AE60`) rendered as a colored rail. `onReaction(emoji, ctx)` fires when a user reacts. |
| `<Header>` | children | Bold title row. |
| `<Section>` | children | A block of content. |
| `<Markdown>` | children | Markdown text. |
| `<Fields>` | children (`<Field>`) | Groups key/value fields. |
| `<Field>` | `label?: string`, children | Label renders on Slack/Discord/Teams; surfaces without field labels fall back to the value text alone. |
| `<Context>` | children | Small secondary/muted context text. |
| `<Divider />` | none | Horizontal rule. |
| `<Image>` | `url: string`, `alt?: string` | Image block. |
| `<Table>` | `columns?: { header, align? }[]`, children (`<Row>`) | Structured table. |
| `<Row>` | children (`<Cell>`) | Table row. |
| `<Cell>` | children | Table cell. |
| `<Chart>` | `type?`, `title?`, `xAxisTitle?`, `yAxisTitle?`, `data: {label, value}[]` | `type` is one of `verticalBar` (default), `horizontalBar`, `line`, `pie`, `donut`. Platforms without native charts skip the node. |

## Interactive components

Interactive controls live inside `<Actions>`, the container for buttons, selects, and inputs.

### `<Button>`

<ParamField body="onClick" type="(ctx) => void | Promise<void>">
  Click handler. Receives the handler context; `ctx.action.value` is typed from `value`.
</ParamField>
<ParamField body="value" type="any">
  Value echoed back to the handler on click, and the value `thread.awaitChoice<T>` resolves to.
</ParamField>
<ParamField body="url" type="string">
  When set, the button becomes a **link button** and `onClick`/`value` are ignored.
</ParamField>
<ParamField body="style" type='"primary" | "danger"'>
  Slack accent styling.
</ParamField>

### `<Select>`

<ParamField body="onSelect" type="(ctx) => void | Promise<void>">
  Selection handler. `ctx.action.value` is a `string`, or `string[]` when `multi` is set.
</ParamField>
<ParamField body="options" type="{ label, value }[]" required>
  The selectable options.
</ParamField>
<ParamField body="placeholder" type="string">
  Placeholder text.
</ParamField>
<ParamField body="multi" type="boolean">
  Multi-select. Renders as `multi_static_select` on Slack, max-values on Discord, `isMultiSelect` on Teams; Telegram and WhatsApp degrade to single-select.
</ParamField>

### `<Input>`

<ParamField body="onSubmit" type="(ctx) => void | Promise<void>">
  Submit handler. `ctx.action.value` is the entered text.
</ParamField>
<ParamField body="placeholder" type="string">
  Placeholder text.
</ParamField>
<ParamField body="multiline" type="boolean">
  Multi-line text entry.
</ParamField>
<ParamField body="name" type="string">
  Field name.
</ParamField>

```tsx
<Actions>
  <Button value="approve" style="primary"
    onClick={async ({ action, thread, messageRef }) => {
      await thread.update(messageRef, <Section>Approved ✓</Section>);
    }}>
    Approve
  </Button>
  <Select
    placeholder="Pick an environment"
    options={[{ label: "Staging", value: "staging" }, { label: "Production", value: "production" }]}
    onSelect={async ({ action, thread }) => {
      await thread.post(<Section>Selected {String(action.value)}</Section>);
    }}
  />
</Actions>
```

<Note>
Handlers must return `void | Promise<void>`. A concise arrow returning `thread.post(...)` fails under `strict` because `post` returns a `MessageRef` — use a block body and `await` the call.
</Note>

## Modal components

A modal is a separate IR root (`ModalView`), not a message. Open it with `ctx.openModal(view)` from a `CommandContext` — `openModal` is optional and `undefined` on surfaces with no trigger for it, so keep the `?.`. Submissions and dismissals route back to `channel.onModalSubmit(callbackId, …)` and `channel.onModalClose(callbackId, …)` by `callbackId`, **not** to inline handlers. Return `{ errors }` from a submit handler to keep the modal open with field errors.

| Component | Props | Notes |
| --- | --- | --- |
| `<Modal>` | `callbackId: string`, `title: string`, `submitLabel?`, `closeLabel?`, `notifyOnClose?`, `privateMetadata?` | The view root. `notifyOnClose` makes Slack emit `view_closed`. `privateMetadata` is an opaque string echoed back to the handlers. |
| `<TextInput>` | `id: string`, `label: string`, `placeholder?`, `multiline?`, `optional?`, `maxLength?`, `initialValue?` | Free-text field. Read it from `evt.values[id]`. |
| `<ModalSelect>` | `id: string`, `label: string`, `placeholder?`, `optional?`, `initialOption?` | Children are `<ModalSelectOption>`. `initialOption` is an option's `value`. |
| `<ModalSelectOption>` | `label: string`, `value: string` | An option inside `<ModalSelect>` or `<RadioButtons>`. |
| `<RadioButtons>` | `id: string`, `label: string`, `optional?`, `initialOption?` | Children are `<ModalSelectOption>`. |

### Call `Modal(...)`, don't write `<Modal>`

The JSX runtime declares `JSX.Element = ChannelNode`, so every JSX expression is typed `ChannelNode` — which erases the `ModalView` narrowing that `openModal` requires. `<Modal …/>` therefore fails under `strict`:

```text
error TS2345: Argument of type 'ChannelNode' is not assignable to parameter of type 'ModalView'.
```

Call the component as a plain function and pass `children` as a prop. Its children can still be JSX, because only the root must stay a `ModalView`:

```tsx
channel.onCommand("feedback", async ({ openModal }) => {
  await openModal?.(
    Modal({
      callbackId: "feedback",
      title: "Send feedback",
      submitLabel: "Send",
      children: [
        <TextInput id="body" label="What happened?" multiline />,
        <RadioButtons id="severity" label="Severity">
          <ModalSelectOption label="Blocking" value="high" />
          <ModalSelectOption label="Annoying" value="low" />
        </RadioButtons>,
      ],
    }),
  );
});

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

`thread` is optional on `ModalSubmitEvent` — a submission may arrive without a conversation context.

## Handler context shapes

Inline handlers (`onClick`, `onSelect`, `onSubmit`, `onReaction`) receive a context with at least these fields:

<ResponseField name="action" type="object">
  The activated control. `action.value` is the value echoed back, typed from the `value` prop or the selection (`string`, or `string[]` for multi-select).
</ResponseField>
<ResponseField name="thread" type="Thread">
  The live thread handle. A handler can `thread.post(...)`, `thread.update(messageRef, ...)`, or run a human-in-the-loop flow.
</ResponseField>
<ResponseField name="messageRef" type="MessageRef">
  A reference to the message the control lives in, usable with `thread.update`.
</ResponseField>
<ResponseField name="user" type="ApplicationUser | null">
  The resolved user who activated the control.
</ResponseField>

## Degradation rules

Message rendering and modal rendering degrade differently:

| Surface | Unsupported node behavior |
| --- | --- |
| Message tree (`thread.post` / `update` / `awaitChoice`) | Adapter **skips** the node; the rest of the tree renders. The renderer is total and never throws. |
| Modal view (`openModal`) | Adapter **throws `ModalRenderError`** — modals are not skip-and-degrade. |

Specific documented degradations:

- `<Chart>` — skipped entirely on platforms without native charts.
- `<Field label>` — the label renders on Slack, Discord, and Teams; other surfaces fall back to the value text alone.
- `<Select multi>` — Telegram and WhatsApp degrade to single-select.
- Capability-gated thread methods degrade rather than throw (`getMessages()` returns `[]`, `lookupUser()` returns `undefined`) — the same philosophy applied to the Thread API.

## Handler durability

Inline `onClick`/`onSelect` handlers are bound by **content-stable IDs**: `"ck:" + sha1(name | path | stableStringify(props)).slice(0, 16)`. The same rendered control always produces the same ID, so a click long after posting still resolves to the right handler — as long as the binding still exists.

- Inline handlers route **in-process only**. The default `MemoryStore` is ephemeral, so bindings are lost on restart and a button clicked after a redeploy won't resolve.
- Handlers on a **registered component** (`createChannel({ components })`) with a durable store configured (`createChannel({ store: { adapter, actionRetentionMs } })`) survive a restart; `actionRetentionMs` defaults to 7 days. Without registration, a click on a pre-restart message degrades to "action expired".

## Choosing components

- Announce or inform → `<Message>` with `<Header>`, `<Section>`, `<Markdown>`, `<Fields>`.
- Offer discrete choices → `<Actions>` with `<Button>`s, or `thread.awaitChoice<T>`.
- Free text or an options list → `<Input>` or `<Select>`.
- Structured data → `<Table>` or `<Chart>`.

## Related pages

<CardGroup cols={2}>
  <Card title="Render interactive UI" href="/render-interactive-ui">
    Post one JSX tree that lowers to Block Kit, Adaptive Cards, or Discord components, with agent-rendered components via defineChannelComponent.
  </Card>
  <Card title="Slash commands and modals" href="/slash-commands-and-modals">
    Handle commands with channel.onCommand and open modals with ctx.openModal, routing submissions by callbackId.
  </Card>
  <Card title="Human-in-the-loop approvals" href="/human-in-the-loop">
    Block a tool handler on a typed button choice with thread.awaitChoice, and make approval buttons survive restarts.
  </Card>
  <Card title="Thread API reference" href="/thread-api-reference">
    The per-conversation handle that renders these trees: post, update, awaitChoice, runAgent, and the capability-gated methods.
  </Card>
  <Card title="Author a platform adapter" href="/author-platform-adapter">
    The PlatformAdapter contract behind degradation: total renderers, decodeInteraction, and content-stable ID recovery.
  </Card>
  <Card title="createChannel reference" href="/createchannel-reference">
    The components and store options that make interactive handlers durable across restarts.
  </Card>
</CardGroup>

---

## 17. Configuration reference

> Environment variables and project configuration: INTELLIGENCE_API_KEY, CHANNEL_CODE, PORT, the paired INTELLIGENCE_API_URL / INTELLIGENCE_GATEWAY_WS_URL overrides (bare base URLs, never derived from each other), AGENT_URL for remote AG-UI agents, plus the required tsconfig (jsxImportSource, module settings) and package.json shape (type: module, overrides pin).

- Page Markdown: https://grok-wiki.com/public/docs/copilotkit-channels-sdk-4c947e3e6161/pages/17-configuration-reference.md
- Generated: 2026-08-05T06:41:34.499Z

### Source Files

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

---
title: "Configuration reference"
description: "Environment variables and project configuration: INTELLIGENCE_API_KEY, CHANNEL_CODE, PORT, the paired INTELLIGENCE_API_URL / INTELLIGENCE_GATEWAY_WS_URL overrides (bare base URLs, never derived from each other), AGENT_URL for remote AG-UI agents, plus the required tsconfig (jsxImportSource, module settings) and package.json shape (type: module, overrides pin)."
---

A Channels process reads its configuration from environment variables at startup and fails fast when a required one is missing. Three variables cover the standard managed setup (`INTELLIGENCE_API_KEY`, `CHANNEL_CODE`, `PORT`), two paired variables override the hosted Intelligence endpoints for self-hosted or non-production environments (`INTELLIGENCE_API_URL`, `INTELLIGENCE_GATEWAY_WS_URL`), and `AGENT_URL` points at a remote AG-UI agent when the agent runs outside the listener process. Beyond environment variables, the project itself must be ESM (`"type": "module"`), pin a single copy of `@ag-ui/client` via `overrides`, and — in any project that renders Channels JSX — point the TypeScript JSX factory at `@copilotkit/channels` with `jsxImportSource`.

## Environment variables

| Variable | Required | Purpose |
| --- | --- | --- |
| `INTELLIGENCE_API_KEY` | Yes | Project-scoped CopilotKit Intelligence API key |
| `CHANNEL_CODE` | Yes (managed Channels) | The exact Channel Code from Intelligence, passed as `createChannel({ name })` |
| `PORT` | No (defaults to `3000`) | Port for the lifecycle HTTP server |
| `INTELLIGENCE_API_URL` | Only for self-hosted / non-production | Bare base URL of the Intelligence HTTP API |
| `INTELLIGENCE_GATEWAY_WS_URL` | Only for self-hosted / non-production | Bare base URL of the Intelligence gateway WebSocket |
| `AGENT_URL` | Only for remote AG-UI agents | HTTP URL of an AG-UI-compatible agent, consumed by `HttpAgent` |
| `OPENAI_API_KEY` | Only when using `BuiltInAgent` with an OpenAI model | Model provider credential for the built-in agent |

<ParamField body="INTELLIGENCE_API_KEY" type="string" required>
  The project-scoped API key that authenticates the process to CopilotKit Intelligence. Create it from **API Keys** in the Intelligence project sidebar. A key is required for every Channel — there is no standalone or DIY way to run one. Keep `.env` out of source control and never put this key (or any platform token) in browser code.
</ParamField>

<ParamField body="CHANNEL_CODE" type="string" required>
  Must equal the Channel Code exactly as shown in Intelligence; pass it as the `name` option of `createChannel`. Codes are 3–64 characters, start with a lowercase letter, use lowercase letters and digits separated by single hyphens, are unique per project, and are never the literal `channels`. The value is validated by the runtime, not by `createChannel` — a typo fails at startup and leaves the Channel at **Waiting for runtime** in the dashboard rather than erroring at the call site.
</ParamField>

<ParamField body="PORT" type="number" default="3000">
  Port for the HTTP server created around the listener. Managed message delivery arrives over the Channel's own gateway socket, not this port — but keep the server: it serves the runtime's web requests, and most hosts require a listening port for health checks. The reference listener reads it as `Number(process.env.PORT ?? 3000)`.
</ParamField>

<ParamField body="AGENT_URL" type="string">
  HTTP URL of a remote AG-UI-compatible agent. Pass it to `HttpAgent` from `@ag-ui/client` (also re-exported from `@copilotkit/channels`) and hand that to `createChannel({ agent })`. The built-in agent (`BuiltInAgent` from `@copilotkit/runtime/v2`) runs in the same Node process and needs no `AGENT_URL` and no second server.
</ParamField>

<ParamField body="OPENAI_API_KEY" type="string">
  Only needed when the agent factory constructs `BuiltInAgent` with an OpenAI model (for example `new BuiltInAgent({ model: "openai:gpt-5.4-mini" })`). A remote AG-UI agent owns its own model credentials.
</ParamField>

### Paired Intelligence URL overrides

Hosted Intelligence supplies both endpoint defaults, so a standard managed Channel sets **neither** variable. Override them only for self-hosted or non-production Intelligence — and always **both together**:

<ParamField body="INTELLIGENCE_API_URL" type="string">
  Bare base URL of the Intelligence HTTP API, passed as `apiUrl` to `new CopilotKitIntelligence({...})`. Example: `https://intelligence.example.com`.
</ParamField>

<ParamField body="INTELLIGENCE_GATEWAY_WS_URL" type="string">
  Bare base URL of the Intelligence gateway WebSocket, passed as `wsUrl` to `new CopilotKitIntelligence({...})`. Example: `wss://realtime.intelligence.example.com`.
</ParamField>

<Warning>
The API host and the gateway host are **separate hosts**. Never derive `wsUrl` from `apiUrl` by swapping `https://` for `wss://` — override both or neither. Pass bare base URLs only: the client appends `/api/...`, `/runner`, `/client`, or `/channels` itself, so appending path segments yourself breaks the connection.
</Warning>

```ts title="Wiring the overrides into CopilotKitIntelligence"
import { CopilotKitIntelligence } from "@copilotkit/runtime/v2";

const intelligence = new CopilotKitIntelligence({
  apiKey: required("INTELLIGENCE_API_KEY"),
  // Both optional; hosted Intelligence supplies the defaults.
  apiUrl: process.env.INTELLIGENCE_API_URL,
  wsUrl: process.env.INTELLIGENCE_GATEWAY_WS_URL,
});
```

### Example .env files

<CodeGroup>

```dotenv title=".env — managed Channel (hosted Intelligence)"
INTELLIGENCE_API_KEY=<project-api-key>
CHANNEL_CODE=support-slack
PORT=3000

# Optional paired overrides for self-hosted or non-production Intelligence:
# INTELLIGENCE_API_URL=https://intelligence.example.com
# INTELLIGENCE_GATEWAY_WS_URL=wss://realtime.intelligence.example.com
```

```dotenv title=".env — remote AG-UI agent (examples/minimal-channel)"
AGENT_URL=<http-url-of-your-ag-ui-agent>
INTELLIGENCE_API_URL=<intelligence-api-url>
INTELLIGENCE_GATEWAY_WS_URL=<intelligence-gateway-ws-url>
INTELLIGENCE_API_KEY=<project-api-key>
```

</CodeGroup>

## Loading environment variables

The documented start command loads `.env` through Node's native flag — no dotenv dependency required:

```sh
node --env-file=.env --import tsx channel.ts
```

The `examples/minimal-channel` project instead loads `dotenv` in `lib/env.ts` (honoring a `DOTENV_CONFIG_PATH` override) and wraps every read in a fail-fast helper, so a missing variable stops the process at import time instead of producing a half-configured Channel:

```ts title="examples/minimal-channel/lib/env.ts"
import { config } from "dotenv";

config({ path: process.env.DOTENV_CONFIG_PATH, quiet: true });

export const required = (name: string) => {
  const value = process.env[name];
  if (!value) throw new Error(`Missing ${name}`);
  return value;
};
```

Either approach works; the invariant is that required variables are asserted before `createChannel` and `CopilotKitIntelligence` consume them.

<Note>
A Channel needs **Node.js 22+** (the launcher requires global `WebSocket`) and a long-running process. A serverless request handler cannot host one — the process must own a persistent gateway connection.
</Note>

## tsconfig requirements

Any project that renders Channels JSX (`<Message>`, `<Button>`, …) must compile JSX against Channels, not React. Files containing JSX must use the `.tsx` extension, and the tsconfig must set `jsxImportSource`:

```json title="tsconfig.json — Channels project with JSX"
{
  "compilerOptions": {
    "target": "ES2022",
    "jsx": "react-jsx",
    "jsxImportSource": "@copilotkit/channels",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "strict": true,
    "types": ["node"]
  }
}
```

Constraints that matter:

- **`jsxImportSource: "@copilotkit/channels"`** — without it, the JSX tree compiles against React and fails. Point it at the package you actually installed; do **not** point it at `@copilotkit/channels-ui` unless that package is a direct dependency. It is only a transitive dependency of the umbrella package, so the import resolves under npm's hoisted layout but fails under pnpm's isolated one.
- **`module` / `moduleResolution`** — `nodenext`/`nodenext` for a Node-executed project. The `examples/minimal-channel` project (which posts no JSX) uses `"module": "ESNext"` with `"moduleResolution": "Bundler"`, `"noEmit": true`, and a `"@/*"` path alias instead; both are valid ESM shapes.
- **`strict: true`** — the SDK's typed surfaces (the branded non-optional `listener.channels`, handler return types, `awaitChoice<T>`) are exercised under strict mode in every shipped example.

## package.json shape

Three properties are load-bearing:

```json title="package.json — required shape"
{
  "type": "module",
  "dependencies": {
    "@copilotkit/channels": "0.6.1",
    "@copilotkit/runtime": "1.65.0"
  },
  "overrides": {
    "@ag-ui/client": "0.0.57"
  }
}
```

### type: module

The listener uses top-level `await` (`await channels.ready(...)`), so the project must be ESM. Set it with:

```sh
npm pkg set type=module
```

Without it, compilation fails with `TS1309: The current file is a CommonJS module`.

### Version-locked package pair

`@copilotkit/channels` and `@copilotkit/runtime` ship together as a tested pair — install with `--save-exact` and upgrade both together. Known-good pairs are `0.6.1` + `1.65.0` and `0.7.1` + `1.66.1` (`defineChannelComponent` and the native-node helpers are 0.7+ only).

### The @ag-ui/client overrides pin

Channels and Runtime both depend on one exact `@ag-ui/client` version, but a transitive dependency (`@ag-ui/mcp-middleware`) pulls an older one, and npm nests it. Two copies mean two separate `AbstractAgent` declarations, so passing *any* agent to `createChannel({ agent })` fails with a confusing error about "separate declarations of a private property `_debug`". Pin one copy to the version Runtime declares (`npm ls @ag-ui/client` shows both), then reinstall:

<Tabs>
<Tab title="npm">

```json
{ "overrides": { "@ag-ui/client": "0.0.57" } }
```

</Tab>
<Tab title="pnpm">

```json
{ "pnpm": { "overrides": { "@ag-ui/client": "0.0.57" } } }
```

</Tab>
<Tab title="yarn">

```json
{ "resolutions": { "@ag-ui/client": "0.0.57" } }
```

</Tab>
</Tabs>

This duplicate is the single most likely reason a correct-looking Channel refuses to typecheck. The `examples/minimal-channel` project sidesteps it by declaring `@ag-ui/client` as a direct dependency at the matching version.

## Related pages

<CardGroup cols={2}>
  <Card title="Installation" href="/installation">
    The full install sequence: the version-locked package pair, `npm pkg set type=module`, the overrides pin, and tsconfig setup.
  </Card>
  <Card title="Quickstart" href="/quickstart">
    Build the first managed Channel end to end, set the four environment variables, and start with `node --env-file`.
  </Card>
  <Card title="Minimal Channel example" href="/minimal-channel-example">
    The smallest complete listener file by file, including the fail-fast `lib/env.ts` loading pattern.
  </Card>
  <Card title="Troubleshooting" href="/troubleshooting">
    The `_debug` private-property compile error, TS1309 from missing `type: module`, and other documented failure modes.
  </Card>
</CardGroup>

---

## 18. Minimal Channel example

> The smallest complete listener, file by file: lib/channel.ts (createChannel plus onMention/onMessage with the subscribe pattern), lib/runtime.ts (CopilotRuntime with an explicit CopilotKitIntelligence connection), lib/env.ts (fail-fast env loading), and server.ts (ready() gate, HTTP server, SIGINT teardown) — with the 🪁 no-model-call verification trick.

- Page Markdown: https://grok-wiki.com/public/docs/copilotkit-channels-sdk-4c947e3e6161/pages/18-minimal-channel-example.md
- Generated: 2026-08-05T06:42:32.954Z

### Source Files

- `examples/minimal-channel/README.md`
- `examples/minimal-channel/server.ts`
- `examples/minimal-channel/lib/channel.ts`
- `examples/minimal-channel/lib/runtime.ts`
- `examples/minimal-channel/lib/env.ts`
- `examples/minimal-channel/package.json`
- `examples/minimal-channel/.env.example`

---
title: "Minimal Channel example"
description: "The smallest complete listener, file by file: lib/channel.ts (createChannel plus onMention/onMessage with the subscribe pattern), lib/runtime.ts (CopilotRuntime with an explicit CopilotKitIntelligence connection), lib/env.ts (fail-fast env loading), and server.ts (ready() gate, HTTP server, SIGINT teardown) — with the 🪁 no-model-call verification trick."
---

`examples/minimal-channel/` is the smallest complete Channels listener in this repository: a long-running Node.js process (Node 22+) that registers one managed Channel with CopilotKit Intelligence, blocks startup on `channels.ready()`, and answers Slack mentions by running an AG-UI agent reachable over HTTP. It is four source files plus configuration, ported from `tylerslaton/minimal-channel`, and runs with `tsx` directly from TypeScript — no build step.

## File layout

:::files
```
examples/minimal-channel/
├── server.ts          # ready() gate, node:http server, SIGINT teardown
├── lib/
│   ├── channel.ts     # createChannel + onMention/onMessage subscribe pattern
│   ├── runtime.ts     # CopilotRuntime + CopilotKitIntelligence + node listener
│   └── env.ts         # dotenv load + fail-fast required()
├── package.json       # type: module, tsx scripts, pinned SDK pair
├── tsconfig.json      # strict, noEmit, "@/*" path alias
└── .env.example       # the four required variables
```
:::

The import chain is strictly one-directional — each file owns one concern and the entry point only touches the listener:

```text
server.ts ──▶ lib/runtime.ts ──▶ lib/channel.ts ──▶ lib/env.ts
 (HTTP +        (Intelligence       (Channel +         (dotenv +
  lifecycle)     connection +        handlers)          required())
                 listener)
```

## lib/env.ts — fail-fast environment loading

Loads `.env` once at import time and exposes a `required()` helper that throws immediately on a missing variable, so a misconfigured process dies at startup instead of failing on the first message:

```ts title="lib/env.ts"
import { config } from "dotenv";

config({ path: process.env.DOTENV_CONFIG_PATH, quiet: true });

export const required = (name: string) => {
  const value = process.env[name];
  if (!value) throw new Error(`Missing ${name}`);
  return value;
};
```

`DOTENV_CONFIG_PATH` optionally redirects which file is loaded; when unset, dotenv falls back to `.env` in the working directory.

## lib/channel.ts — createChannel and the subscribe pattern

Declares one managed Channel named `scratch` (no `adapters` array — Intelligence supplies the platform connection) backed by an `HttpAgent` pointing at any AG-UI-compatible agent:

```ts title="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 };
```

The two handlers together implement the subscribe pattern: `onMention` marks the conversation subscribed (a persisted per-conversation flag) and runs the agent; `onMessage` fires for every message but only runs the agent when `thread.isSubscribed()` is true. The net behavior is "answer the mention, then keep answering every follow-up in that conversation without requiring re-mentions."

## lib/runtime.ts — CopilotRuntime with an explicit Intelligence connection

Constructs the `CopilotKitIntelligence` connection from three environment variables, attaches the Channel to a `CopilotRuntime`, and exports the Node listener. Creating the listener is what starts the Channel — there is no `channel.start()`:

```ts title="lib/runtime.ts"
import {
  CopilotRuntime,
  CopilotKitIntelligence,
} from "@copilotkit/runtime/v2";
import {
  createCopilotNodeListener
} from "@copilotkit/runtime/v2/node";
import { channel } from "@/lib/channel";
import { required } from "@/lib/env";

const intelligence = new CopilotKitIntelligence({
  apiUrl: required("INTELLIGENCE_API_URL"),
  wsUrl: required("INTELLIGENCE_GATEWAY_WS_URL"),
  apiKey: required("INTELLIGENCE_API_KEY"),
});

const runtime = new CopilotRuntime({
  agents: {},
  intelligence,
  identifyUser: () => ({ id: "scratch-user", name: "Scratch User" }),
  channels: [channel],
});

export const listener = createCopilotNodeListener({
  runtime,
  basePath: "/api/copilotkit",
});
```

Three details worth copying exactly:

- `agents: {}` is required on `CopilotRuntime` even though the Channel supplies its own agent.
- `apiUrl` and `wsUrl` are two independent bare base URLs — one is never derived from the other.
- `identifyUser` here returns a static stub user, the minimum needed to satisfy the required option; real Channels resolve the platform identity instead.

## server.ts — ready() gate, HTTP server, SIGINT teardown

The entry point awaits activation, serves the listener over plain `node:http`, and tears down on Ctrl-C:

```ts title="server.ts"
import { createServer } from "node:http";
import { listener } from "@/lib/runtime";

await listener.channels?.ready({ timeoutMs: 15_000 });

const server = createServer(listener);

server.listen(3000, () => {
  console.log("\nServer listening on port 3000...");
});

process.on("SIGINT", async () => {
  await listener.channels?.stop();
  server.close();
});
```

The SIGINT handler stops the Channel first (`channels.stop()` detaches it from the Intelligence gateway) and closes the HTTP server second, so the dashboard reflects a clean shutdown rather than a dropped connection.

<Warning>
`ready()` settles activation but is not proof of life: it also resolves when the Channel lands in `setup_required` (declared but unprovisioned in Intelligence). For a deploy you can trust, additionally gate on `channels.status().overall === "online"` — see the lifecycle page for the full status table.
</Warning>

## Configuration

The example reads four environment variables, all required (missing values throw at import time via `required()`):

<ParamField body="AGENT_URL" type="string" required>
  HTTP URL of an AG-UI-compatible agent. Passed to `new HttpAgent({ url })` in `lib/channel.ts`.
</ParamField>

<ParamField body="INTELLIGENCE_API_URL" type="string" required>
  Bare base URL of the CopilotKit Intelligence API. Becomes `apiUrl` on `CopilotKitIntelligence`.
</ParamField>

<ParamField body="INTELLIGENCE_GATEWAY_WS_URL" type="string" required>
  Bare WebSocket base URL of the Intelligence gateway. Becomes `wsUrl`; set independently of the API URL.
</ParamField>

<ParamField body="INTELLIGENCE_API_KEY" type="string" required>
  Project API key from CopilotKit Intelligence. Becomes `apiKey`.
</ParamField>

Project configuration is minimal but load-bearing: `package.json` sets `"type": "module"` and pins the SDK pair (`@copilotkit/channels` `0.2.0` with `@copilotkit/runtime` `1.63.0`, plus `@ag-ui/client` `^0.0.57` and `dotenv`); `tsconfig.json` uses `strict`, `noEmit`, `moduleResolution: "Bundler"`, and maps the `@/*` alias to the example root. There is no `jsxImportSource` because this example posts no JSX UI.

## Run it

<Steps>
<Step title="Prerequisites">
Node.js 22 or later, a Channel created in CopilotKit Intelligence with Slack connected, and an AG-UI-compatible agent reachable over HTTP.
</Step>
<Step title="Install and configure">
```sh
cd examples/minimal-channel
pnpm install
cp .env.example .env
```
Fill in all four variables in `.env`.
</Step>
<Step title="Start the listener">
```sh
pnpm dev
```
This runs `tsx watch server.ts`. The process prints `Server listening on port 3000...` only after `ready()` settles.
</Step>
<Step title="Verify end to end">
When the Intelligence dashboard shows **Online**, invite the app to a Slack channel and mention it. The agent replies in the thread; follow-up messages in that conversation are answered without further mentions, courtesy of the subscribe pattern.
</Step>
</Steps>

## The 🪁 no-model-call verification trick

The example README documents a cheap way to prove the whole pipe — platform event → Intelligence ingress → your process → reply delivery — before spending a single model call: replace the body of `onMention` with a handler that posts one static `🪁` message instead of calling `thread.runAgent()`. A kite appearing in the Slack thread confirms the Channel Code, gateway connection, and delivery path are all correct, with the agent completely out of the loop.

Once the kite round-trips, restore the real handler — the README suggests `thread.runAgent({ prompt: message.text })` as the next step; the shipped `lib/channel.ts` uses the plain `thread.runAgent()` form combined with the subscribe pattern shown above. This isolates failures cleanly: if the kite never arrives, the problem is configuration or Channel provisioning, not your agent.

## Related pages

<CardGroup cols={2}>
  <Card title="Quickstart" href="/quickstart">
    The guided path to the same shape: create the Channel in Intelligence, set the four variables, and verify `status().overall === "online"`.
  </Card>
  <Card title="Channel lifecycle and status" href="/channel-lifecycle">
    Why listener creation starts the Channel, what `ready()` does and does not guarantee, and teardown ordering for SIGINT/SIGTERM.
  </Card>
  <Card title="Handle mentions, messages, and subscriptions" href="/handle-conversations">
    All ten channel handlers, forwarding `contentParts`, and the full subscribe()/isSubscribed() pattern.
  </Card>
  <Card title="Configuration reference" href="/configuration-reference">
    Every environment variable, including the paired API/gateway URL overrides and the required tsconfig and package.json shape.
  </Card>
  <Card title="Managed Channels vs direct adapters" href="/managed-vs-direct">
    Why this example carries no adapter or platform tokens, and when the direct-adapter path applies instead.
  </Card>
  <Card title="OpenTag reference application" href="/opentag-reference-app">
    The production-shaped counterpart: same runtime pieces, plus a LangGraph agent, two surfaces, and approval-gated writes.
  </Card>
</CardGroup>

---

## 19. OpenTag reference application

> The flagship Channels app vendored as a pinned git submodule: what it demonstrates (Python LangGraph agent over AG-UI, Slack and Teams surfaces, file-aware prompts, approval-gated Linear/Notion writes), how to fetch it with git submodule update --init, its prerequisites, and the deliberate one-line workflow for bumping the pin.

- Page Markdown: https://grok-wiki.com/public/docs/copilotkit-channels-sdk-4c947e3e6161/pages/19-opentag-reference-application.md
- Generated: 2026-08-05T06:44:14.352Z

### Source Files

- `examples/README.md`
- `.gitmodules`
- `README.md`
- `AGENTS.md`

---
title: "OpenTag reference application"
description: "The flagship Channels app vendored as a pinned git submodule: what it demonstrates (Python LangGraph agent over AG-UI, Slack and Teams surfaces, file-aware prompts, approval-gated Linear/Notion writes), how to fetch it with git submodule update --init, its prerequisites, and the deliberate one-line workflow for bumping the pin."
---

[OpenTag](https://github.com/CopilotKit/OpenTag) is an open-source, self-hosted on-call triage assistant for Slack and Microsoft Teams, and the flagship application built on the Channels SDK. This repository vendors it at `examples/OpenTag` as a **git submodule** — a single pinned commit of OpenTag's `main` branch, declared in `.gitmodules` — rather than a copied source tree, so no duplicated code can drift out of sync. A plain `git clone` of this repository leaves `examples/OpenTag` empty until you initialize the submodule.

## What it demonstrates

OpenTag is the reference for how the pieces documented across this site fit together in one complete, production-shaped application:

| Capability | How OpenTag shows it |
| --- | --- |
| Agent backend | A Python LangGraph agent connected over AG-UI, running as its own agent service |
| Runtime wiring | One `CopilotKitIntelligence`, one `CopilotRuntime`, one adapter-free managed Channel |
| Surfaces | Native Slack and Microsoft Teams experiences from the same Channel code |
| Files and UI | File-aware prompts and generative UI rendered into the conversation |
| Human-in-the-loop | Human approval gates before Linear or Notion writes |
| Deployment shape | A production-shaped Node runtime process alongside the Python agent service |

The repository's `build-channels-agent` skill (`.agents/skills/build-channels-agent/SKILL.md`) names OpenTag as the reference app and instructs agents to mirror it whenever a task is close to "a full Slack agent app."

## Fetch the submodule

<Steps>
<Step title="Initialize after a plain clone">

A regular clone records only the submodule pointer. Populate `examples/OpenTag` with:

```sh
git submodule update --init examples/OpenTag
```

</Step>
<Step title="Or clone with submodules from the start">

```sh
git clone --recurse-submodules https://github.com/CopilotKit/ChannelsSDK.git
```

</Step>
<Step title="Verify">

`git submodule status` should show the pinned commit without a leading `-` (a leading `-` means uninitialized), and `examples/OpenTag` should contain OpenTag's source, including `README.md` and `setup.md`.

</Step>
</Steps>

<Note>
Once initialized, run and setup instructions live inside the submodule at `examples/OpenTag/README.md` and `examples/OpenTag/setup.md`. Those files are not present in this repository until the submodule is fetched.
</Note>

## Prerequisites

Running OpenTag requires both a Node.js side (the Channels listener) and a Python side (the LangGraph agent):

| Requirement | Purpose |
| --- | --- |
| Node.js 22+ | Long-running Channels runtime process |
| `pnpm` | Node package management |
| Python 3.12 | LangGraph agent service |
| [`uv`](https://docs.astral.sh/uv/) | Python environment and dependency management |
| CopilotKit Intelligence project, Channel, and runtime API key | Managed platform connection (no Slack/Teams tokens in your process) |
| OpenAI API key | Model credentials for the agent |

## Submodule pin model

The pin is defined in `.gitmodules`:

```ini title=".gitmodules"
[submodule "examples/OpenTag"]
	path = examples/OpenTag
	url = https://github.com/CopilotKit/OpenTag.git
	branch = main
```

`branch = main` matters: it makes `git submodule update --remote` follow OpenTag's `main` instead of git's `master` default. The superproject records the submodule as a `160000 commit` tree entry — one commit hash, not a live link — so updates to OpenTag's `main` never appear here automatically.

## Bump the pin

Moving the pin is a deliberate, reviewable one-line change:

```sh
git submodule update --remote examples/OpenTag
git add examples/OpenTag
git commit -m "chore(examples): bump OpenTag submodule"
```

The resulting diff is a single hash change on the `examples/OpenTag` gitlink, which makes the update easy to review and easy to revert.

<Tip>
This vendoring strategy is intentional. The repository previously vendored a full copy of another artifact (the `setup-slack-channel` skill), which drifted ~16 KB behind its upstream and ended up asserting incorrect behavior; `AGENTS.md` documents that failure and the resulting rule of one home per artifact. The submodule pin applies the same principle to OpenTag: one source of truth, fetched on demand, moved only by an explicit commit.
</Tip>

## Related pages

<CardGroup cols={2}>
<Card title="Architecture and the runtime boundary" href="/architecture">
The turn flow OpenTag implements at full scale: platform event → Intelligence → your Channels process → agent over AG-UI → native UI back.
</Card>
<Card title="Minimal Channel example" href="/minimal-channel-example">
The smallest complete listener in `examples/minimal-channel` — the stripped-down counterpart to OpenTag.
</Card>
<Card title="Human-in-the-loop approvals" href="/human-in-the-loop">
The approval-gate pattern OpenTag uses before Linear and Notion writes.
</Card>
<Card title="Managed Channels vs direct adapters" href="/managed-vs-direct">
Why OpenTag runs an adapter-free managed Channel with no platform tokens in the process.
</Card>
</CardGroup>

---

## 20. Troubleshooting

> Documented failure modes with fixes: the duplicate @ag-ui/client "_debug private property" compile error, TS1309 from missing type: module, TS2345 ModalView narrowing, JSX compiled against React, setup_required and Waiting for runtime states, ready() resolving on a degraded Channel, Unknown option '--skill' from a stale CLI, and the invented Bot-prefixed APIs that never existed.

- Page Markdown: https://grok-wiki.com/public/docs/copilotkit-channels-sdk-4c947e3e6161/pages/20-troubleshooting.md
- Generated: 2026-08-05T06:43:10.838Z

### Source Files

- `.agents/skills/build-channels-agent/SKILL.md`
- `AGENTS.md`
- `.agents/skills/build-channels-agent/references/ui-components.md`
- `.agents/skills/build-channels-agent/references/hitl-patterns.md`
- `README.md`

---
title: "Troubleshooting"
description: "Documented failure modes with fixes: the duplicate @ag-ui/client \"_debug private property\" compile error, TS1309 from missing type: module, TS2345 ModalView narrowing, JSX compiled against React, setup_required and Waiting for runtime states, ready() resolving on a degraded Channel, Unknown option '--skill' from a stale CLI, and the invented Bot-prefixed APIs that never existed."
---

Every failure mode on this page is documented in the repository's `build-channels-agent` skill, `AGENTS.md`, or the README, with the exact error text and the verified fix. They fall into three groups: compile-time errors that block a correct-looking project from typechecking, runtime and lifecycle states where a process starts cleanly but never answers, and tooling errors from stale CLI or hallucinated API names.

## Quick index

| Symptom | Cause | Fix |
| --- | --- | --- |
| `separate declarations of a private property '_debug'` | Two nested copies of `@ag-ui/client` | Pin one version with `overrides` |
| `TS1309: The current file is a CommonJS module` | Missing `"type": "module"` with top-level `await` | `npm pkg set type=module` |
| `TS2345: ... 'ChannelNode' is not assignable to ... 'ModalView'` | `<Modal>` written as JSX | Call `Modal({...})` as a function |
| JSX errors referencing React types | Missing `jsxImportSource` in tsconfig | Point it at `@copilotkit/channels` |
| Status stuck at `setup_required` | Channel declared but provider unprovisioned | Finish provider setup in Intelligence — do not switch to a direct adapter |
| Dashboard shows **Waiting for runtime** | `createChannel({ name })` does not match the Channel Code | Fix the `name` / `CHANNEL_CODE` mismatch |
| Process starts, serves HTTP 200, answers nothing | `ready()` resolved on a degraded Channel | Gate startup on `status().overall === "online"` |
| `Unknown option '--skill'` | Stale `copilotkit` CLI on PATH or in the npx cache | Always run `npx copilotkit@latest ...` |
| `createBot` / `defineBotTool` fail to compile | Pre-release names that shipped nowhere | Use `createChannel` / `defineChannelTool` |

## Compile-time failures

### Duplicate `@ag-ui/client`: the `_debug` private property error

Passing any agent to `createChannel({ agent })` fails with a confusing error about *"separate declarations of a private property `_debug`"*. This is the single most likely reason a correct-looking Channel refuses to typecheck.

`@copilotkit/channels` and `@copilotkit/runtime` both depend on one exact version of `@ag-ui/client`, but a transitive dependency (`@ag-ui/mcp-middleware`) pulls an older one and npm nests it. Two copies mean two separate `AbstractAgent` class declarations, and TypeScript treats their private fields as incompatible.

Fix by pinning one copy in `package.json`:

```json
{ "overrides": { "@ag-ui/client": "0.0.57" } }
```

Use the version Runtime declares — `npm ls @ag-ui/client` shows both copies — then reinstall. pnpm uses `pnpm.overrides`; yarn uses `resolutions`.

### TS1309: missing `"type": "module"`

The documented listener uses top-level `await` (`await channels.ready(...)`), which requires ESM. Without `"type": "module"` in `package.json` the compile fails:

```
TS1309: The current file is a CommonJS module
```

Fix:

```sh
npm pkg set type=module
```

This step is part of the documented install sequence, alongside installing `@copilotkit/channels` and `@copilotkit/runtime` as a version-locked pair.

### TS2345: `<Modal>` JSX loses the `ModalView` type

The Channels JSX runtime declares `JSX.Element = ChannelNode`, so every JSX expression is typed `ChannelNode`. That erases the `ModalView` narrowing that `ctx.openModal` requires, and `<Modal .../>` fails under `strict`:

```
error TS2345: Argument of type 'ChannelNode' is not assignable to parameter of type 'ModalView'.
```

Call `Modal` as a plain function and pass `children` as a prop. The children can still be JSX — only the root must stay a `ModalView`:

```tsx
channel.onCommand("feedback", async ({ openModal }) => {
  await openModal?.(
    Modal({
      callbackId: "feedback",
      title: "Send feedback",
      submitLabel: "Send",
      children: [
        <TextInput id="body" label="What happened?" multiline />,
        <RadioButtons id="severity" label="Severity">
          <ModalSelectOption label="Blocking" value="high" />
          <ModalSelectOption label="Annoying" value="low" />
        </RadioButtons>,
      ],
    }),
  );
});
```

Keep the `?.` on `openModal` — it is `undefined` on surfaces with no modal trigger.

### JSX compiled against React

A file containing Channels JSX must be `.tsx`, and the tsconfig must point the JSX factory at Channels. Without `jsxImportSource` the tree compiles against React's JSX types and fails:

```json
{
  "compilerOptions": {
    "target": "ES2022",
    "jsx": "react-jsx",
    "jsxImportSource": "@copilotkit/channels",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "strict": true,
    "types": ["node"]
  }
}
```

<Warning>
Point `jsxImportSource` at `@copilotkit/channels` — the package you installed. Do not point it at `@copilotkit/channels-ui` unless that package is a direct dependency: it is only a transitive dependency of the umbrella package, so the import resolves under npm's hoisted layout but fails under pnpm's isolated one.
</Warning>

### The Bot-prefixed APIs that never existed

`createBot`, `defineBotTool`, `defineBotCommand`, and `BotToolContext` come from a pre-release naming scheme and exist nowhere in the shipped packages — importing them fails to compile. The word "bot" is not part of this API. The same applies to `new Bot()` and `channel.on("message", ...)`.

| Invented name | Real API |
| --- | --- |
| `createBot` | `createChannel` |
| `defineBotTool` | `defineChannelTool` |
| `defineBotCommand` | `defineChannelCommand` |
| `BotToolContext` | `ChannelToolContext` |
| `channel.on("message", fn)` | `channel.onMessage(fn)` / `channel.onMention(fn)` |
| `channel.start()` / `channel.stop()` | No public lifecycle method — attach the Channel to `CopilotRuntime` and create a listener |

Coding agents drafting from memory are the usual source of these names. Ground generated code in the repository's `.agents/skills/build-channels-agent/SKILL.md` rather than in recalled Slack/Discord SDK patterns.

## Runtime and lifecycle states

### Status stuck at `setup_required`

`setup_required` means the Channel is declared but unprovisioned — the provider side (Slack app, Teams app, credentials) has not been finished in CopilotKit Intelligence. The fix is to complete that setup in the Intelligence dashboard; the `setup-slack-channel` skill (installed via `npx copilotkit@latest skills install --skill setup-slack-channel`) covers diagnosing it for Slack.

<Warning>
Swapping to a direct adapter (`adapters: [slack({ botToken, appToken })]`) to escape `setup_required` is itself a documented failure mode, not a fallback. The direct path puts platform secrets in your process and still requires Intelligence — it does not bypass the managed setup. Fix the managed configuration instead.
</Warning>

### Dashboard shows **Waiting for runtime**

`createChannel({ name })` must equal the exact Channel Code from Intelligence: 3–64 characters, starting with a lowercase letter, lowercase letters and digits separated by single hyphens, project-unique, and never the literal `channels`. A mismatch leaves the Channel at **Waiting for runtime** in the dashboard.

The name is validated by the runtime, not by `createChannel`, so a typo fails at startup rather than at the call site. Note the two vocabularies are distinct: `channels.status()` returns SDK values (`connecting`, `online`, `setup_required`, `reconnecting`, `error`, `stopped`), while the Intelligence dashboard uses its own states (Disabled, Setup incomplete, Setup failed, Waiting for runtime, Conflict, Offline, Delivery failing, Online).

### `ready()` resolves on a degraded Channel

`channels.ready()` is not proof of life. It settles activation, but it also resolves on `setup_required`, because a declared-but-unprovisioned Channel is a valid degraded state. Without a status check you get a process that starts cleanly, serves HTTP 200, and answers nothing.

Gate startup on the actual status:

```ts
await channels.ready({ timeoutMs: 30_000 });

const status = channels.status();
if (status.overall !== "online") {
  throw new Error(`Channel is not online: ${JSON.stringify(status)}`);
}

server.listen(Number(process.env.PORT ?? 3000));
```

Activation errors are recorded as status and surfaced through `ready()` / `status()` — they are not thrown. Only an up-front misconfiguration (a duplicate or missing Channel name) throws synchronously. On `error`, `ready()` rejects with the cause.

```mermaid
stateDiagram-v2
    [*] --> connecting : listener created
    connecting --> online : activation succeeds
    connecting --> setup_required : declared but unprovisioned
    connecting --> error : activation fails
    online --> reconnecting : gateway socket drops
    reconnecting --> online : retry succeeds
    online --> stopped : channels.stop()
    setup_required --> stopped : channels.stop()

    note right of setup_required
        ready() RESOLVES here too —
        gate on status().overall === "online"
    end note
    note right of error
        ready() rejects with the cause
    end note
```

<Tip>
To verify the full round trip without spending a model call, use the minimal-channel trick: have `onMention` post a single `🪁` instead of running the agent. Once the kite lands in the thread, swap the handler body for `thread.runAgent(...)`.
</Tip>

## Tooling failures

### `Unknown option '--skill'` from a stale CLI

```sh
npx copilotkit@latest skills install --skill setup-slack-channel -y
```

If this fails with `Unknown option '--skill'`, an older `copilotkit` — globally installed or left in the npx cache — is shadowing the current CLI. Keep the `@latest`; that is what forces npx to fetch the current version instead of reusing what is already on PATH or cached. A bare `npx copilotkit` resolves to whatever is already available.

The same rule applies to `npx copilotkit@latest channels setup` and every other CLI invocation this repository documents.

### Vendored skill drift

This repository once vendored a copy of `setup-slack-channel` under `.agents/skills/`. It fell ~16 KB behind the canonical copy in `CopilotKit/CopilotKit` and ended up asserting the opposite of the truth about Slack interactivity, and `skills install` overwrote the eight tracked files. The skill now has one home and is fetched on demand — install it via the CLI rather than copying it into a project.

## Related pages

<CardGroup cols={2}>
  <Card title="Installation" href="/installation">
    The version-locked package pair, the `overrides` pin for `@ag-ui/client`, ESM enforcement, and the required tsconfig.
  </Card>
  <Card title="Channel lifecycle and status" href="/channel-lifecycle">
    The six SDK status values versus the Intelligence dashboard states, `ready()` semantics, and teardown ordering.
  </Card>
  <Card title="Managed Channels vs direct adapters" href="/managed-vs-direct">
    Why switching to a direct adapter to escape `setup_required` is a known failure mode.
  </Card>
  <Card title="Slash commands and modals" href="/slash-commands-and-modals">
    The `Modal({...})` function-call pattern, `callbackId` routing, and `onModalSubmit` / `onModalClose`.
  </Card>
  <Card title="Set up with a coding agent" href="/coding-agent-setup">
    The `npx copilotkit@latest channels setup` path and the hosted channels-guide.md workflow.
  </Card>
  <Card title="Minimal Channel example" href="/minimal-channel-example">
    The smallest complete listener, including the 🪁 no-model-call verification trick.
  </Card>
</CardGroup>

---
