# Human-in-the-loop approvals

> Gate agent actions on humans: thread.awaitChoice<T> to block a tool handler on a typed button choice, onInterrupt + thread.resume for agent-originated pauses (LangGraph-style interrupts), and making approval buttons survive restarts with a durable StateStore adapter plus registered components.

- Repository: CopilotKit/channels-sdk
- GitHub: https://github.com/CopilotKit/channels-sdk
- Human docs: https://grok-wiki.com/public/docs/copilotkit-channels-sdk-4c947e3e6161
- Complete Markdown: https://grok-wiki.com/public/docs/copilotkit-channels-sdk-4c947e3e6161/llms-full.txt

## Source Files

- `.agents/skills/build-channels-agent/references/hitl-patterns.md`
- `.agents/skills/build-channels-agent/SKILL.md`
- `.agents/skills/build-channels-agent/references/ui-components.md`
- `.agents/skills/build-channels-agent/evals/evals.json`

---

---
title: "Human-in-the-loop approvals"
description: "Gate agent actions on humans: thread.awaitChoice<T> to block a tool handler on a typed button choice, onInterrupt + thread.resume for agent-originated pauses (LangGraph-style interrupts), and making approval buttons survive restarts with a durable StateStore adapter plus registered components."
---

The Channels SDK pauses a run and waits for a human through two mechanisms, chosen by where the pause originates. `thread.awaitChoice<T>(ui)` is for when **your code** asks: it posts a JSX tree and blocks the calling handler until the user activates a control, resolving to that control's `value` typed as `T`. `channel.onInterrupt<T>(event, fn)` plus `thread.resume(value)` is for when **the agent** pauses itself mid-`thread.runAgent()` — a LangGraph-style interrupt. Whether the approval buttons still work after a process restart depends on a third piece: the configured store and component registration.

| Mechanism | Pause originates in | Blocks | Continues via |
| --- | --- | --- | --- |
| `thread.awaitChoice<T>(ui)` | Your handler or tool code | The calling handler | The returned promise resolving to the clicked `value` |
| `channel.onInterrupt<T>(event, fn)` | The agent, during `thread.runAgent()` | The agent run loop | `thread.resume(value)` |

## Gate a tool with thread.awaitChoice

`awaitChoice<T>(ui)` posts `ui` and blocks until the user activates a control, resolving to that control's `value`. Because every tool handler receives the live `thread` in its `ChannelToolContext`, you can call it directly inside a `defineChannelTool` handler to gate a destructive action on explicit approval:

```tsx title="tools/confirm-deploy.tsx"
import { Message, Section, Markdown, Actions, Button } from "@copilotkit/channels";
import type { Thread } from "@copilotkit/channels";

async function confirmDeploy(thread: Thread, env: string) {
  const ok = await thread.awaitChoice<boolean>(
    <Message accent="#E01E5A">
      <Section><Markdown>Deploy to **{env}**? This is irreversible.</Markdown></Section>
      <Actions>
        <Button value={true} style="primary">Ship it</Button>
        <Button value={false} style="danger">Cancel</Button>
      </Actions>
    </Message>,
  );
  return ok;
}
```

Inside a tool, the pattern ends with a natural-language result for the agent, not a status object:

```tsx
if (!ok) return "User cancelled; nothing was deployed.";
```

Constraints that apply to any `awaitChoice` tree:

- The clicked `Button`'s `value` is what the promise resolves to, typed as `T`. A `Button` with `url` set becomes a link button and its `value`/`onClick` are ignored, so it cannot resolve a choice.
- The Channels JSX runtime declares an empty `IntrinsicElements` — `<b>`, `<span>`, `<div>` are compile errors. Emphasis belongs inside `<Markdown>`.
- The file must be `.tsx` and the project's tsconfig must set `jsxImportSource: "@copilotkit/channels"`; otherwise the tree compiles against React and fails.
- For a multi-option approval, a `<Select>` with `options: {label, value}[]` works the same way — the selection's `value` resolves the choice (a `string`, or `string[]` when `multi`).

## Agent-originated pauses: onInterrupt + thread.resume

When the agent itself pauses during `thread.runAgent()` — for example a LangGraph interrupt — register a handler for the interrupt event name the agent emits, render a prompt, and re-enter the run with the value the agent expects:

```ts title="channel.ts"
channel.onInterrupt<{ question: string }>("ask_human", async ({ thread, payload }) => {
  const answer = await thread.awaitChoice<string>(
    /* a <Select> or <Button> group built from payload.question */
  );
  await thread.resume(answer); // agent continues from where it paused
});
```

```mermaid
sequenceDiagram
    participant Agent as Agent (runAgent loop)
    participant Channel as Channel process
    participant User as Human on the platform

    Agent->>Channel: interrupt event "ask_human" + payload
    Channel->>Channel: onInterrupt handler fires
    Channel->>User: awaitChoice posts approval UI
    User-->>Channel: clicks a Button / picks a Select option
    Channel->>Agent: thread.resume(value)
    Agent-->>Channel: run continues, next MessageRef
```

Behavior details:

- The interrupt handler receives `{ payload, thread, user, actor }`; `payload` is typed by the generic parameter.
- The event name passed to `onInterrupt` must match what the agent emits.
- `thread.resume(value)` re-enters the run loop with `value` and returns the next `MessageRef`, or `undefined`.
- `resume(value, { memory, subject })` accepts the same Intelligence Memory grant shape as `runAgent` (`{ user?, project? }`, each `"none" | "read" | "read-write"`). Omitting the grant disables Memory for the resumed run — there is no implicit access.

## Making approvals survive restarts

Interactive handlers are keyed by **content-stable IDs**: `"ck:" + sha1(name | path | stableStringify(props)).slice(0, 16)`. The same rendered control always produces the same ID, so a click on an old message maps back to the right handler — as long as the binding still exists. The binding lives in the configured store, and that is where durability is decided.

```text
Click on an approval button, after a restart
┌────────────────────────┬───────────────────────────────┬─────────────────────────┐
│ Handler kind           │ Store                         │ Result                  │
├────────────────────────┼───────────────────────────────┼─────────────────────────┤
│ Inline onClick closure │ any                           │ lost — in-process only  │
│ Registered component   │ MemoryStore (default)         │ lost — binding is gone  │
│ Registered component   │ durable StateStore adapter    │ handler re-bound, works │
└────────────────────────┴───────────────────────────────┴─────────────────────────┘
```

Two things are required together:

1. **A durable store.** The default is the in-memory `MemoryStore` — bindings are lost on restart, so a button clicked after a redeploy does not resolve. Implement the `StateStore` interface (persisting to Redis, Postgres, or similar) and pass it as the store adapter.
2. **Registered components.** Pass the component via `createChannel({ components })` (a `defineChannelComponent` component) so its handlers can be re-bound after restart. Without registration, a click on a message posted before the restart degrades to "action expired" even with a durable store.

```ts title="channel.ts"
const channel = createChannel({
  identifyUser: "platform",
  store: {
    adapter: myRedisStore,
    actionRetentionMs: 7 * 24 * 60 * 60 * 1000, // default 7 days
  },
  components: [IssueCard], // register components so handlers can be re-bound
});
```

<ParamField body="store.adapter" type="StateStore">
  A durable implementation of the `StateStore` interface. Replaces the default in-memory `MemoryStore`.
</ParamField>

<ParamField body="store.actionRetentionMs" type="number" default="604800000">
  How long action bindings are retained. Defaults to 7 days.
</ParamField>

<ParamField body="components" type="ChannelComponent[]">
  Components defined with `defineChannelComponent`, registered so their keyed handlers can be recovered after a restart.
</ParamField>

<Warning>
`createChannel({ actionStore })` still works but is deprecated — configure `store.adapter` instead.
</Warning>

<Tip>
Rule of thumb: for a demo or a short-lived prompt, the in-memory default is fine. For approval buttons that must work hours later or across deploys, configure a durable store and use registered components rather than one-off inline closures.
</Tip>

## Common mistakes

- Returning `{ ok: true }` from an approval-gated tool. The return value goes back to the agent — return short natural-language text ("User cancelled; nothing was deployed.") so the model can act on it.
- Using lowercase intrinsic tags in the approval UI. `<b>` and `<span>` do not exist in this JSX runtime; put emphasis inside `<Markdown>`.
- Expecting an inline `onClick` closure to survive a redeploy. Inline handlers route in-process only; durability requires a registered component plus a durable store.
- Configuring a durable `store.adapter` but not registering the component — clicks on pre-restart messages still degrade to "action expired".
- Resuming an interrupted run and expecting Memory access without a grant — `resume(value, { memory })` must state the grant explicitly, same as `runAgent`.

## Related pages

<CardGroup cols={2}>
  <Card title="Add tools" href="/add-tools">
    The `ChannelToolContext` shape that puts the live `thread` inside a tool handler, and return-value rules for approval results.
  </Card>
  <Card title="Render interactive UI" href="/render-interactive-ui">
    The Actions/Button/Select vocabulary used in approval prompts, content-stable IDs, and `defineChannelComponent`.
  </Card>
  <Card title="Thread API reference" href="/thread-api-reference">
    Full signatures for `awaitChoice`, `resume`, `runAgent`, and the rest of the per-conversation thread handle.
  </Card>
  <Card title="createChannel reference" href="/createchannel-reference">
    Every `createChannel` option, including `store` (adapter, actionRetentionMs, concurrency) and `components`.
  </Card>
  <Card title="Handle mentions, messages, and subscriptions" href="/handle-conversations">
    Where `onInterrupt` sits among the ten channel handlers and how turns reach the agent.
  </Card>
</CardGroup>
