Approvals
Pause an agent before it runs a sensitive tool, let a person decide — now or hours later — and continue the run where it stopped.
Overview
A tool with requiresApproval never runs without a person's say-so. When the model calls it, the run either asks right away (onApproval) or pauses: it returns with status: 'paused' and the calls that wait, keeps its state, and goes on with cogitator.resume() once someone decided — a minute later, after a Telegram reply, or after the server restarted.
const refund = tool({
name: 'refund',
description: 'Refund an order',
parameters: z.object({ order: z.string(), amount: z.number() }),
requiresApproval: ({ amount }) => amount > 100, // small refunds need no one
sideEffects: ['external'],
execute: async ({ order, amount }) => payments.refund(order, amount),
});
const result = await cog.run(support, {
input: 'Refund order A-1, it arrived broken',
threadId,
userId,
});
if (result.status === 'paused') {
for (const call of result.pendingApprovals!) {
console.log(`${call.toolName}(${JSON.stringify(call.arguments)}) needs approval`);
}
}Nothing of the paused turn has run: if the model asked for several tools at once, all of them wait, so a decision never comes after side effects already happened.
requiresApproval is true (always ask) or a function of the model's arguments as sent — before schema parsing and defaults. A function that throws counts as "needs approval". Each waiting call in pendingApprovals is a ToolApprovalRequest: { toolCallId, toolName, arguments, description, sideEffects? }, where description is the tool's description.
Resuming
The runtime keeps the paused run per thread. Resume it with a decision for each call — declined calls answer the model with the reason, so it can tell the user or try something else:
const done = await cog.resume(support, threadId, {
userId,
decisions: {
[callId]: { approved: true },
// or { approved: false, reason: 'refunds over $100 need a manager' }
},
});defaultDecision answers every waiting call at once ({ approved: true } for "approve all"). Calls without a decision keep the run paused. Resuming by thread id checks the caller: only the user the run belongs to may resume it (THREAD_ACCESS_DENIED otherwise), and a thread with nothing paused answers RUN_NOT_PAUSED.
If the user writes something new instead of deciding, the waiting calls are answered as declined ("the user moved on") and the new run starts from a consistent history.
Deciding Inline
When a person can answer while the run waits — a CLI prompt, a confirm dialog over a WebSocket — decide inline. Return 'pause' to fall back to pausing:
await cog.run(agent, {
input,
onApproval: async (call) => {
const ok = await confirm(`Run ${call.toolName} with ${JSON.stringify(call.arguments)}?`);
return ok ? { approved: true } : { approved: false, reason: 'declined in the CLI' };
},
});Without onApproval, guardrails.onToolApproval ((toolName, args, sideEffects) => Promise<boolean>) decides when it is set; otherwise the run pauses. With guardrails strictMode, every call of a tool with sideEffects goes through the same flow, as if the tool had requiresApproval. onApproval is a run option, so it also works for cog.resume(), where it decides calls the model makes after resuming.
Agents wrapped with agentAsTool() cannot pause: unless you pass onApproval to agentAsTool, their calls that need approval are declined (as they are when that onApproval returns 'pause') and the inner agent is told why.
Where Paused Runs Live
| Setup | Paused runs are kept in |
|---|---|
memory configured | the thread's metadata — as durable as the memory (Redis, Postgres, SQLite, MongoDB) |
| no memory | process memory |
runCheckpoints: myStore | your RunCheckpointStore (save, load(threadId), delete(threadId)) |
The checkpoint holds the whole conversation, so it stays on the server. You can also keep it yourself — result.checkpoint is plain JSON — and pass it back: cog.resume(agent, checkpoint, { decisions }).
Servers and Chats
- Server adapters answer a paused run with
status: 'paused'andpendingApprovals, stream anapproval-requiredevent, and take decisions onPOST /agents/:name/resume({ threadId, decisions?, defaultDecision? }). - Channels ask in the chat ("reply approve or deny") and resume when the user answers — see Gateway approvals.