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

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

## Source Files

- `.agents/skills/build-channels-agent/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>
