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:

tsconfig.json
{
  "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:

approval-card.tsx
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>
  );
}
channel.tsx
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:

  1. The agent emits an on_interrupt event.
  2. channel.onInterrupt posts the registered component and returns.
  3. Intelligence acknowledges the original turn.
  4. A later click updates the card when possible and calls thread.resume(value).
  5. 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.Submit buttons 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.