Interactive messages and approvals
Render portable Slack and Teams UI, handle actions, and resume interrupted agent runs without blocking a managed delivery.
Channels JSX renders portable components as native channel UI. Use it for buttons, summaries, and human approval without maintaining provider payloads by hand.
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.
Enable Channels JSX#
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"]
}Post a native action#
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"),
identifyUser: "platform",
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.
Use post-and-resume for managed approvals#
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.
For an agent that emits the SDK-supported on_interrupt event, the safe flow
is:
- The agent emits an
on_interruptevent. channel.onInterruptposts the registered component and returns.- Intelligence acknowledges the original turn.
- A later click updates the card when possible and calls
thread.resume(value). - The SDK re-enters the agent with the approval result.
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.
Framework interrupt support is not universal
The validated managed path is a DeepAgent tool-driven approval that emits
on_interrupt, returns from delivery, then resumes from a later interaction.
For every other backend, follow its human-in-the-loop guide and run a real
provider test that confirms the interrupt reaches onInterrupt, the handler
returns, the later click calls resume, and the agent consumes the value. Do
not assume native AG-UI interrupts are portable. The default
sanitizeAgentEvents behavior repairs a known event-validation issue; it
does not add framework interrupt support by itself.
Restart durability needs a StateStore
The managed realtime connection does not automatically persist SDK action
snapshots. The default MemoryStore keeps them only in the current process.
Registered components make recovery possible, but clicks on pre-restart
cards still require a durable
createChannel({ store: { adapter } }) backend. See
Threads and state.
Capability differences#
Text and sections are the portable baseline. Interactive behavior also depends on the selected provider's app configuration:
- Teams can deliver
Action.Submitbuttons in Adaptive Cards as message activities.
Other provider-specific features remain capability-gated:
- Incoming reactions can be handled when Intelligence delivers them, and the managed adapter supports adding or removing documented portable reactions.
- Modal open/submit callbacks are direct-adapter capabilities, not part of the managed Slack or Teams realtime path.
- Ephemeral messages, conversation titles, and suggested prompts may return
{ 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.