Human approval

Name the tools a person must approve. When the model calls one, the run pauses before any tool of that turn runs and returns the waiting calls. The pause is saved with the thread, so the decision can come minutes or days later, from another process, a dashboard or a webhook. Use approval for actions that cost money, reach other people or cannot be undone: refunds, emails, orders, prescriptions, deletions.

from criogenai import Agent, Decision, Storage

agent = Agent(
    "openai:gpt-5-mini",
    tools=[issue_refund],
    approve=["issue_refund"],  # a person decides before it runs
    storage=Storage("sqlite://support.db"),
)

chat = agent.thread("ticket-7", actor="user:ada")
reply = await chat.send("Order A-1001 arrived broken, please refund it")
if reply.needs_approval:
    for request in reply.approvals:
        print(request.tool, request.args)

# Hours later, in another process: the approval waited in the database.
reply = await agent.thread("ticket-7", actor="user:dr-rao").approve()
# Or: await chat.reject("Refund policy says no")
#     await chat.decide({call_id: Decision.edit({"order_id": "A-1001", "amount": 12})})
import { Agent, Decision, Storage } from "criogenai";

const agent = new Agent({
  model: "openai:gpt-5-mini",
  tools: [issueRefund],
  approve: ["issue_refund"], // a person decides before it runs
  storage: new Storage("sqlite://support.db"),
});

const chat = agent.thread("ticket-7", { actor: "user:ada" });
let reply = await chat.send("Order A-1001 arrived broken, please refund it");
if (reply.needsApproval) {
  for (const request of reply.approvals) console.log(request.tool, request.args);
}

// Hours later, in another process: the approval waited in the database.
reply = await agent.thread("ticket-7", { actor: "user:dr-rao" }).approve();
// Or: await chat.reject("Refund policy says no")
//     await chat.decide({ [callId]: Decision.edit({ orderId: "A-1001", amount: 12 }) })
use criogenai_agent::{Agent, Approval};
use criogenai_storage::Storage;

let agent = Agent::builder(model)
    .tool(refund)
    .approval(Approval::new().tool("issue_refund"))
    .storage(Storage::connect("sqlite://support.db").await?)
    .build()?;

let chat = agent.thread("ticket-7").actor("user:ada");
let reply = chat
    .send("Order A-1001 arrived broken, please refund it")
    .await?;
if reply.needs_approval() {
    for request in reply.approvals() {
        println!(
            "{} wants {}",
            request.tool,
            serde_json::Value::Object(request.args)
        );
    }
}

// Hours later, in another process: the approval waited in the database.
let reply = agent
    .thread("ticket-7")
    .actor("user:dr-rao")
    .approve()
    .await?;
// Or .reject("Refund policy says no"), or one decision per call:
// .decide([(call_id, Decision::edit(new_args))]) / Decision::respond("Already refunded").

What happens, step by step#

  1. The model replies with one or more tool calls, and at least one of them is in approve.
  2. The run stops before running any call of that reply, including the ones that need no approval, and saves a suspended checkpoint.
  3. send returns a reply with needs_approval (needsApproval) set and text empty. approvals lists the calls that wait: call_id, tool, args (and, in TypeScript, the decisions allowed).
  4. Later, any process with the same storage calls approve, reject or decide on that thread. The decided calls run (or not), the other calls of the turn run, and the loop continues until the model answers or asks for another approval.

Approval needs a thread (agent.thread(id)), and a SQLite or Postgres Storage if the decision may come from another process or after a restart.

Decisions#

DecisionWhat happensThe model sees
approveThe call runs as the model wrote itThe tool's result
editThe call runs with your arguments (or as another tool)The result, with a note saying a reviewer changed the call and what ran
rejectThe call does not runrejected, with your reason
respondThe call does not runYour text, as the tool's result

approve() and reject(reason) answer every waiting call at once. To decide each call on its own, use decide with one Decision per call_id:

reply = await chat.send("Refund order A-1001 and email the customer")
decisions = {}
for request in reply.approvals:
    if request.tool == "issue_refund" and request.args["amount"] > 100:
        # Run it with other arguments; the model is told what actually ran.
        decisions[request.call_id] = Decision.edit({**request.args, "amount": 100})
    elif request.tool == "send_email":
        # Do not run it; this text is the tool's result.
        decisions[request.call_id] = Decision.respond("Not sent: the customer opted out of email.")
    else:
        decisions[request.call_id] = Decision.approve()
reply = await chat.decide(decisions)  # one decision per pending call, or resume_invalid
const ticket = support.thread("ticket-9", { actor: "user:dr-rao" });
const pending = await ticket.send("Refund order A-1001 and email the customer");
const decisions: Record<string, Decision> = {};
for (const request of pending.approvals) {
  if (request.tool === "issue_refund" && Number(request.args.amount) > 100) {
    // Run it with other arguments; the model is told what actually ran.
    decisions[request.call_id] = Decision.edit({ ...request.args, amount: 100 });
  } else if (request.tool === "send_email") {
    // Do not run it; this text is the tool's result.
    decisions[request.call_id] = Decision.respond("Not sent: the customer opted out of email.");
  } else {
    decisions[request.call_id] = Decision.approve();
  }
}
const decided = await ticket.decide(decisions); // one decision per pending call, or resume_invalid

decide must answer every waiting call. A decision that does not fit is refused with resume_invalid, and the thread keeps waiting, unchanged:

  • a waiting call has no decision;
  • an unknown call_id;
  • edited arguments that fail the tool's schema (the error names the problem).

While it waits#

  • send on the thread fails with thread_suspended until the approval is answered. Answer first, or reject to move on.
  • Nothing about the pause lives in memory: the process can exit, and another one can decide. It needs the same storage and an agent built the same way (the same tools and approve list); an agent built differently gets workflow_mismatch or resume_invalid. Answer waiting approvals before you deploy a change to them.
  • CriogenAI does not notify anyone. Store the waiting calls where your reviewers look (a table, a queue, a chat message) when send returns needs_approval, together with the thread id.
  • The thread's history() shows the pause as a checkpoint with status suspended.

Who decided#

Thread handles carry an actor: agent.thread(id, actor="user:dr-rao") ({ actor: "user:dr-rao" }). Use one handle for the customer and another for the reviewer, as in the tutorial. With the audit trail on, the request, the decision and its actor are recorded, and the decision is recorded before the call runs. CriogenAI records the actor you give; checking that this person may approve is your application's job.

Streaming the continuation#

stream_approve(), stream_reject(reason) (streamApprove, streamReject, streamDecide) continue the run and stream its events, like stream does for send. A stream emits approval_needed when a run pauses.

Approval in workflows#

In a workflow, any node can pause with suspend(payload) and receive the answer as ctx.resume.

An agent used as a workflow node pauses the workflow when it needs approval. The interrupt's payload is {"kind": "agent_approval", "requests": [...]}; resume with that node's decisions, keyed by call id:

call_id = turn.interrupts[0].payload["requests"][0]["call_id"]
await app.thread("case-7").resume({"agent": {call_id: Decision.approve()}})

An agent used as a tool of another agent (as_tool) cannot pause: if it needs approval, its call fails. Use it as a workflow node instead.

Errors#

CodeWhen
thread_suspendedsend while an approval waits
resume_invalidA decision that does not fit, or approve with nothing waiting
workflow_mismatchThe agent deciding was built without the approve list the run paused with
audit_failedThe audit trail is on and the decision could not be recorded: the call does not run

Next#