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

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

## Source Files

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