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#
- The model replies with one or more tool calls, and at least one of them is
in
approve. - The run stops before running any call of that reply, including the
ones that need no approval, and saves a
suspendedcheckpoint. sendreturns a reply withneeds_approval(needsApproval) set andtextempty.approvalslists the calls that wait:call_id,tool,args(and, in TypeScript, the decisionsallowed).- Later, any process with the same storage calls
approve,rejectordecideon 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#
| Decision | What happens | The model sees |
|---|---|---|
| approve | The call runs as the model wrote it | The tool's result |
| edit | The call runs with your arguments (or as another tool) | The result, with a note saying a reviewer changed the call and what ran |
| reject | The call does not run | rejected, with your reason |
| respond | The call does not run | Your 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_invalidconst 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_invaliddecide 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#
sendon the thread fails withthread_suspendeduntil the approval is answered. Answer first, orrejectto 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
approvelist); an agent built differently getsworkflow_mismatchorresume_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
sendreturnsneeds_approval, together with the thread id. - The thread's
history()shows the pause as a checkpoint with statussuspended.
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#
| Code | When |
|---|---|
thread_suspended | send while an approval waits |
resume_invalid | A decision that does not fit, or approve with nothing waiting |
workflow_mismatch | The agent deciding was built without the approve list the run paused with |
audit_failed | The audit trail is on and the decision could not be recorded: the call does not run |
Next#
- Conversations (threads): what a thread guarantees.
- Hooks: block or change calls by rule, without a person.
- Encryption, audit, retention: the record of who approved what.
- API: Python
Thread, TypeScriptThread.