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

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