needsApproval and the run pauses before calling it, until a human
(or your code) approves or rejects the call. The pause is saved in an
ApprovalStore, so the decision can come minutes or days later, from another
request or another process, and the run then continues where it stopped.
Which calls pause
needsApproval is true, or a predicate that receives the validated
arguments, typed from the tool’s zod input:
needsApproval from the server’s tool annotations: readOnlyHint: true runs, while destructiveHint true or absent (the MCP default) asks. Choose
per server with approval: 'annotations' | 'always' | 'never' or a function; see
MCP tool approval.
Permission policies
permissions sets rules for the whole agent instead of tool by tool: a list of
{ tool, when?, action, reason? } rules, checked in order for every tool call
before the tool’s own needsApproval. The first rule that matches decides:
allowruns the call, without approval even ifneedsApprovalwould ask. AneedsApprovalthat denies the call still denies it.denydoes not run it. The model gets a tool error withkind: 'denied'and the rule’sreason(see Tool errors), and streams seetool.error.askpauses the run for approval, exactly likeneedsApproval(or asks theapprovecallback).
needsApproval decides, as before.
tool is a name, a list of names, a RegExp tested against the name, or
'*' for every tool. when narrows a rule to some calls: it gets the
validated arguments (after preToolCall hooks) and { toolName, toolCallId, sessionId }, and may be async; when it throws, the call fails with that
error. allow(tools), deny(tools, reason?) and ask(tools) build the
common rules.
onPermissionDecision is the audit log: it is called once per tool call
(except calls already refused for invalid arguments) with
{ toolName, toolCallId, decision, rule?, args?, at }. decision is the
matching rule’s action or 'default' when none matched, rule is that rule’s
{ index, reason? }, at is an ISO timestamp, and args is left out when the
run sets redactContent. Streams get the same entry as a permission.decision
event (see Streaming). Both are only produced when the agent
sets permissions or onPermissionDecision.
Both options also exist on AgentExecutor.execute() / stream() and
resumeAfterApproval(). Sub-agents inherit the lead agent’s rules, checked
before the sub-agent’s own, and report their decisions to the lead’s
onPermissionDecision.
Approve, deny or ask
AneedsApproval function may return more than a boolean. It gets the
validated arguments and { toolName, toolCallId, sessionId, messages }, and
returns (or resolves to):
'ask'ortrue: pause for approval, as before.'approve'orfalse: run the call.'deny'or{ deny: reason }: do not run it and do not pause. The model gets a tool error withkind: 'denied'and thereason, so it can try something else; streams seetool.error, andonPermissionDecisionrecordsdecision: 'deny'with thereason(and norule).
always() (the same as true),
never() (false) and once(). once() asks the first time the tool is
called in a session; once a human approves a call, later calls of that tool in
the same session run without asking. A rejection is not remembered, and a new
session asks again. once({ per: 'args' }) remembers approvals per tool and
arguments instead, so a call with different arguments asks again. The memory
lives in the transcript (the approved call’s tool message carries
metadata.approval), so it is saved wherever the session, checkpoint or
approval snapshot is, and holds after a resume in another process. Without a
session, each send() is its own transcript. Compacting away that message
makes the tool ask again.
permissions, a deny rule always wins, and the tool’s needsApproval
is not called. Otherwise the tool’s own 'deny' (or { deny }) always
denies, whatever rule matched. An allow rule replaces the tool’s ask (so it
skips 'ask', true and once()), and an ask rule pauses even when the
tool would approve. With no matching rule, the tool’s needsApproval outcome
applies. A preToolCall hook runs before all of these and can deny the call
first (see Hook outcomes). MCP
tools keep their annotation-derived needsApproval (a boolean). Sub-agents
evaluate their tools’ needsApproval the same way, and a once() approval
given through the lead agent is remembered for the rest of that sub-agent’s
task.
createAgent() agents
- A paused
send()resolves (it does not throw) withfinishReason: 'awaiting-approval'and anapprovalId. agent.approvals.list()returns the pending calls this agent paused on in this process, oldest first:{ id, toolCallId, toolName, args, createdAt }, plussubagentPathwhen the call belongs to a sub-agent.agent.approvals.resolve({ id, approved, note? })runs the call (approved) or gives the model a rejection with yournote(rejected), continues the run, and resolves with the continued run’s result, which may pause again. It throws whenidis unknown or already resolved.- A run that paused inside
agent.session()continues in that session: the tool call, its result and the final answer join the session’s transcript. agent.stream()andsession.stream()end at the pause with anapproval.requestedevent andrun.done('awaiting-approval'); resolve it the same way.
InMemoryApprovalStore unless you pass
approvalStore or a store with approvals. To decide a pause after a
restart, give the agent a durable store, such as the SQLite one (see
Choosing a store):
list() only knows the pauses made by this agent object; keep the
approvalId (or read the store) to resolve a pause from somewhere else. A
continued run joins a session only when it is resolved through the agent that
owns that session object.
Resuming with a changed agent
An approval snapshot carries the paused agent’s fingerprint, and resolving it with an agent whose model, tools or instructions differ warns ('warn', the
default), rejects with LOUSHO_AGENT_DRIFT (onAgentDrift: 'error', the
approval stays pending) or carries on ('ignore'). An approved call whose tool
no longer exists always rejects with LOUSHO_RESUME_TOOL_MISSING. See
Resuming with a changed agent.
On a resumed approved call the pre-tool hooks run again, and the arguments they
leave (whether through { input } or by changing ctx.args in place) must equal
what was approved, key order aside; otherwise the call is refused with a
kind: 'validation' tool error. A hook that redacts or normalizes its input
should run on the paused run too, so the human approves the redacted input.
Deciding in code
Passapprove to decide each call as it comes up instead of pausing: true
runs the tool, false sends the model a rejection. It applies to send(),
sessions and agent.approvals.resolve(); stream() still ends at the pause.
Streaming the continued run
agent.approvals.streamResolve(decision, { signal }) and
agent.approvals.streamAnswer({ id, answer }, { signal }) continue the run
like resolve() and answer(), but return the AgentRun that
agent.stream() returns (see Streaming):
run.start, the decided call’s tool.start and tool.done (tool.error for
a rejection), then the continuation’s events up to run.done. run.result
is what resolve() resolves with. A continuation that pauses again ends with
approval.requested and run.done ('awaiting-approval'), even with an
approve callback, as stream() does. A pause made inside a session
continues in that session, and run.done comes once the transcript is saved.
Asking the user a question
createAgent({ askQuestion: true }) gives the agent the built-in
ask_question tool (off by default; askQuestionTool() returns the same tool
to pass in tools yourself). Its input is
{ question: string; options?: string[]; allowFreeText?: boolean }. A call
pauses the run through the approval mechanism, so it waits exactly like an
approval: in sessions, in durable stores and across a restart.
- The pending record has
kind: 'question'andquestion: { text, options?, allowFreeText? }, inagent.approvals.list(), in theapprovecallback and on theapproval.requestedevent, so a UI can render a question instead of an approve button. agent.approvals.answer({ id, answer })(the same asresolve({ id, approved: true, note: answer })) continues the run. The model gets{ answer, option? }, whereoptionis the index of the matching entry ofoptions(case-insensitive). WithallowFreeText: false, an answer outside the options reaches the model as a tool error.resolve({ id, approved: false, note? })declines: the model gets akind: 'rejected'tool error saying the user declined to answer.- An
approvecallback may return a string to answer in code, which is handy in tests and scripted agents.
allows ask_question skips the pause, so the call
fails with “No answer”; leave the tool to its default.
AgentExecutor and resumeAfterApproval()
With AgentExecutor.execute() directly, pass an approvalStore. On a gated
call the executor persists an ExecutionSnapshot instead of invoking the
tool. Resume later, after a real restart if you like, with
resumeAfterApproval():
resumeAfterApproval(decision, store, registry, provider, options?, checkpointStore?)
takes most of the execution options of execute() (for example signal,
onAgentEvent, exporter). With sessionId + checkpointStore, the pause is also
checkpointed, and execute() with that sessionId throws
SessionAwaitingApprovalError until the approval is decided, so a pending
approval cannot be bypassed (see Durable execution).
streamResumeAfterApproval() takes the same arguments and streams the
continued run as an AgentRun (see
Streaming after an approval).
Elsewhere
- Sub-agents. A gated tool inside a sub-agent pauses the lead run; the
lead’s
agent.approvalsresolves it. See Approvals inside a sub-agent. - Workspace tools. The shell tool is approval-gated by default. See Workspace tools.
- MCP. Approval-gated tools cannot be approved over MCP; see Serve an agent over MCP.
- Agent Forge shows pending approvals as inline cards in its chat; see Agent Forge.