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

- 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

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