Human-in-the-loop

Pause a run for a person's decision, then pick up exactly where it stopped.

Some decisions need a person: sending an email to a customer, pushing to main, or deciding whether a payment that may have gone through should be tried again. threads handles this by parking the run.

What parking means

A parked run has stopped and is waiting for someone. Nothing is lost and nothing keeps running: the reason and what it waits on are written to the thread's log, so it can wait minutes or weeks, survive restarts, and continue in another process.

run returns a parked result with:

  • reason: why it stopped.
    • awaiting_approval: a tool call needs approval under your permissions.
    • effect_unknown: a side effect may or may not have happened (for example, the process crashed mid-call) and threads can't tell on its own. See Durability.
  • pending: what it waits on, each with a kind and an id. approval (a challenge id), effect (an effect key) or child (a subagent's thread id, when the wait is inside a subagent).

Get a thread handle

Approvals and other controls are methods on a thread handle. In Python the result's thread already is one. In TypeScript, open it from the store with openThread.

const result = await assistant.run("Email Bob hello", { store });
// result.status === "parked", result.reason === "awaiting_approval"

const opened = await openThread(store, result.thread.id);
if (!opened.ok) throw new Error(opened.error.message);
const thread = opened.value;

The handle needs only the store and the thread id, not the agent, so a different process (a web app, an admin script) can open it later.

Approve or deny

List what is waiting

pendingApprovals() returns each open challenge with the tool, its input, and rules you could keep.

Decide

approve or deny it, naming the principal who decided. Each challenge can be decided once.

Continue

Run the agent on the same thread. The approved call runs first (or the denial is shown to the model), then the new input starts the next turn.

for (const pending of await thread.pendingApprovals()) {
  console.log(pending.tool, pending.input); // send_email { to: "bob@example.com", body: "Hi" }
  const decided = await thread.approve(pending.challenge_id, me);
  if (!decided.ok) throw new Error(decided.error.message);
}

// The approved call runs first, then the new input starts the next turn.
const next = await assistant.run("Anything else?", { store, thread: result.thread });

Here me is the principal deciding, for example { issuer: "api", tenant: "local", subject: "operator" } (Principal(...) from threads.log in Python). Only a principal of the thread's tenant may decide. Through the host, the agent's approvers decide who that is.

You can also remember a rule or give a reason:

// Approve and remember: later send_email calls on this thread run without asking.
await thread.approve(challengeId, me, { rememberRule: "send_email" });
// Or deny, with a reason the model sees.
await thread.deny(challengeId, me, { reason: "Don't email customers from staging." });

The remembered rule must be one of the challenge's suggested_rules.

Behind the host, you don't continue the thread yourself: after an approval through the HTTP API or a channel's approval buttons, the host picks the thread back up on its own. See HTTP API + SSE.

Settle an uncertain side effect

When a run parks with effect_unknown, threads doesn't know whether a call with side effects went through, so it won't guess. Check with the provider (did the email go out? was the card charged?), then tell threads what you found:

  • assume_done: it happened. The call is recorded as done and not repeated.
  • assume_not_done: it didn't happen. The call may run again. This is recorded as your decision to accept the risk of a duplicate.
async function settle(parked: typeof result): Promise<void> {
  if (parked.status !== "parked") return;
  const opened = await openThread(store, parked.thread.id);
  if (!opened.ok) throw new Error(opened.error.message);
  for (const address of parked.pending) {
    if (address.kind !== "effect") continue;
    // You checked the provider: the email never went out, so it is safe to send again.
    const settled = await opened.value.resolveParked(address.id, "assume_not_done", me);
    if (!settled.ok) console.log(settled.error.code);
  }
}

Then continue the thread as above. Tools can avoid most of these stops by declaring how to check their own effects; see Durability.

Cancel

cancel(principal) stops a thread and every unfinished subagent under it. Work that already started and may have had effects is parked for you to settle rather than dropped.

// Cancel a thread and every subagent under it.
const cancelled = await thread.cancel(me);
console.log(cancelled.ok);

In TypeScript you can also pass an AbortSignal as signal to run.

Every control is recorded

Approvals, denials, settled effects, mode changes and cancellations are all written to the thread's log with the principal who made them. You can see who decided what, and when, in the timeline.

Edit on GitHub

On this page