Back to Copilotkit

Interactive messages and approvals

showcase/shell-docs/src/content/docs/channels/interactive/index.mdx

1.66.08.7 KB
Original Source

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.

<Callout type="info" title="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](/channels/intelligence). </Callout> </FrontendOnly> <FrontendOnly frontend="teams">

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.

</FrontendOnly>

Enable Channels JSX

The package is ESM-only. Keep "type": "module" in package.json and add:

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:

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>
  );
}
<FrontendOnly frontend="slack">
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} />);
  },
);
</FrontendOnly> <FrontendOnly frontend="teams">
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} />);
  },
);
</FrontendOnly>

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.

<Callout type="info" title="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. </Callout> <Callout type="warn" title="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](/channels/threads-and-state). </Callout>

Capability differences

Text and sections are the portable baseline. Interactive behavior also depends on the selected provider's app configuration:

<FrontendOnly frontend="slack">
  • 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.
</FrontendOnly> <FrontendOnly frontend="teams">
  • Teams can deliver Action.Submit buttons in Adaptive Cards as message activities.
</FrontendOnly>

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.