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 Slack, Channels JSX renders as Block Kit. Button clicks return as
block_actions events through the signed managed webhook.
Use the current Slack manifest
Newly generated Slack manifests enable Interactivity and configure the managed callback URL. If the app was created from an older manifest, open the Channel in Intelligence, return to its Slack setup instructions, apply the generated manifest, and reinstall the app before testing buttons. After the reinstall, use the current Bot User OAuth token in Intelligence in case Slack reissued it. See Configure the Channel in Intelligence.
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:
- Slack can deliver buttons, selects, and inputs through Block Kit. Current generated manifests enable Interactivity; for an older app, open the Channel in Intelligence, apply the generated manifest from its Slack setup instructions, and reinstall it. See Configure the Channel in Intelligence.
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.