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:

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:

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