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

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

## Source Files

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