showcase/shell-docs/src/content/docs/channels/interactive/index.mdx
Channels JSX renders portable components as native channel UI. Use it for buttons, summaries, and human approval without maintaining provider payloads by hand.
<FrontendOnly frontend="slack">On Slack, Channels JSX renders as Block Kit. Button clicks return as
block_actions events through the signed managed webhook.
On Microsoft Teams, Channels JSX renders as Adaptive Cards. Buttons render with
Action.Submit, and clicks return to the managed connection as message
activities. A Teams Action.Submit activity can arrive without a source
message ref; in that case, message.ref.id is empty.
The package is ESM-only. Keep "type": "module" in package.json and add:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"jsx": "react-jsx",
"jsxImportSource": "@copilotkit/channels",
"strict": true,
"noEmit": true
},
"include": ["*.ts", "*.tsx"]
}
Define a named component and register it on the Channel:
import {
Actions,
Button,
Header,
Message,
Section,
} from "@copilotkit/channels/ui";
export function ApprovalCard({ summary }: { summary: string }) {
return (
<Message>
<Header>Approval required</Header>
<Section>{summary}</Section>
<Actions>
<Button
style="primary"
value={{ approved: true }}
onClick={async ({ thread, message, action }) => {
const value = action.value;
if (value === undefined) {
await thread.post("The approval value was missing. Please try again.");
return;
}
if (message.ref.id) {
try {
await thread.update(
message.ref,
<Message>
<Header>Approved</Header>
<Section>{summary}</Section>
</Message>,
);
} catch {
// Updating the card is best-effort; always resume the agent.
}
}
await thread.resume(value);
}}
>
Approve
</Button>
<Button
style="danger"
value={{ approved: false }}
onClick={async ({ thread, message, action }) => {
const value = action.value;
if (value === undefined) {
await thread.post("The approval value was missing. Please try again.");
return;
}
if (message.ref.id) {
try {
await thread.update(
message.ref,
<Message>
<Header>Rejected</Header>
<Section>{summary}</Section>
</Message>,
);
} catch {
// Updating the card is best-effort; always resume the agent.
}
}
await thread.resume(value);
}}
>
Reject
</Button>
</Actions>
</Message>
);
}
import { createChannel } from "@copilotkit/channels";
import { ApprovalCard } from "./approval-card.js";
import { makeAgent } from "./agent.js";
function required(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Missing ${name}`);
return value;
}
const channel = createChannel({
name: required("CHANNEL_CODE"),
provider: "slack",
agent: makeAgent,
components: [ApprovalCard],
});
channel.onInterrupt<{ summary: string }>(
"on_interrupt",
async ({ payload, thread }) => {
await thread.post(<ApprovalCard summary={payload.summary} />);
},
);
import { createChannel } from "@copilotkit/channels";
import { ApprovalCard } from "./approval-card.js";
import { makeAgent } from "./agent.js";
function required(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Missing ${name}`);
return value;
}
const channel = createChannel({
name: required("CHANNEL_CODE"),
provider: "teams",
agent: makeAgent,
components: [ApprovalCard],
});
channel.onInterrupt<{ summary: string }>(
"on_interrupt",
async ({ payload, thread }) => {
await thread.post(<ApprovalCard summary={payload.summary} />);
},
);
Registration lets the action registry rebuild a named component when a later click arrives. Keep component props JSON-serializable.
Treat an interaction message as updateable only when message.ref.id is
non-empty. The card update in this example is best-effort: a missing ref or a
failed update must not prevent thread.resume(value) from continuing the
approval.
Managed deliveries cannot wait inside thread.awaitChoice(). The click arrives
as a separate claimed delivery, so blocking the first delivery would prevent the
approval from being processed.
The safe flow is:
on_interrupt event.channel.onInterrupt posts the registered component and returns.thread.resume(value).The selected agent framework decides how it emits and consumes the interrupt.
Follow its human-in-the-loop guide for the agent-side
interrupt; keep the Channel handler limited to portable presentation and
resume.
Text and sections are the portable baseline. Interactive behavior also depends on the selected provider's app configuration:
<FrontendOnly frontend="slack">Action.Submit buttons in Adaptive Cards as message
activities.Other provider-specific features remain capability-gated:
{ ok: false }; check the result when your workflow depends on them.Use the JSX callback reference for handler arguments and the Thread API for post, update, and resume signatures.