Approvals
An approval turns a tool call into a question. The workflow stops before the call runs, records who may answer, waits, and resumes only when a designated approver signals a decision. Because the wait happens inside a durable Temporal workflow, it survives worker restarts and can last as long as it needs to. Approval is a dimension of the effective policy, not a flag on a tool. The agent editor’s per-tool “requires approval” checkbox looks like a flag, but it is a view onto a policy rule — the API translates it into one and reads it back. A task cannot switch approval off for itself, and the requirement only ever tightens as it moves down the scopes.The problem
Some actions should not be taken by a model unsupervised — refunding a payment, deleting a record, sending mail to a customer. Two naive designs both fail. Blocking them outright is not the answer: the agent then cannot do the job it was built for, and someone will remove the restriction rather than lose the workflow. Putting the pause at the execution boundary does not work either. A tool call runs inside a Temporal activity, and an activity has a timeout and cannot wait for a human. An interceptor at that boundary can only fail the call — which reads as an error, not a pending decision, and loses the model’s context in the process. The pause has to happen where the durable state lives.How AgentArea approaches it
Writing the rule
Approval is expressed as a policy rule with effectapproval, and the target
decides its shape:
*ortool:*— global. Setsrequires_human_approval: true; every capability tool call pauses. Approvers fromparams.approversapply to all of them.tool:<name>— per tool. Adds the name toescalation_rules, and storesparams.approversunderapprovers_by_tool[<name>]so distinct tools keep distinct signoff lists rather than pooling into one.
user:<id>, group:<id>, or a
userset <type>:<id>#<relation> — and are validated at write time. A bare id is
rejected so the data stays relationship-native.
Most approval rules are not written by hand. Ticking “requires approval” on a
tool in the agent editor reconciles an agent-scoped approval rule targeting
tool:<name>, and unticking it deletes the row rather than disabling it — the
checkbox is the source of truth. The flag is not persisted alongside the agent’s
tool configuration; it is reconstituted from the rules on read, so the two cannot
drift. Every agent-creation path reconciles through the same function, including
bundle install, workspace import and catalog fork.
One naming subtlety is worth knowing when a rule appears not to fire: policy
judges the name the model calls. A code toolset’s namespace is collapsed
(agentarea/shell becomes shell), while an MCP tool keeps the name it
advertises.
The requirement is monotonic across scopes. A workspace that sets
requires_human_approval cannot have it turned off by an agent, user or task
layer; the resolver raises a validation error instead of accepting the weaker
document.
No approval rule ships in the default workspace baseline. Human-in-the-loop is
something you opt into.
Two fields named for approval, one of which does nothing
The API exposes two similarly-named approval fields at different layers. Only one of them gates anything, and confusing them produces a task that runs unapproved.requires_user_confirmation on an agent’s tool configuration is enforced.
This is the checkbox described above: transport only, lifted into an agent-scoped
rule, folded into the snapshot under approval, and read by the workflow gate. A
tool ticked here does pause the run.
requires_human_approval on TaskCreate is not enforced. It is a public
field in the schema, and it is carried faithfully: into the task row’s metadata,
into the execution request, and onto the workflow’s goal object. Nothing reads it
after that. The gate consults the policy snapshot, never the goal. Setting it to
true and setting nothing else creates a task that runs to completion without
ever asking a human.
The two are not related in code despite the near-identical names. Approval is
granted by a policy rule, and TaskCreate has no way to write one.
The pause
decide_tool_policy returns REQUIRE_APPROVAL when requires_human_approval is
true or the tool is in escalation_rules. The workflow’s tool gate then:
- Creates a pending escalation with a fresh id, the tool call id, the tool name,
its arguments, and the resolved approver list for that specific tool
(
approvers_by_toolwins over the global list, falling back to empty). - Sets the workflow status to
WAITING_FOR_APPROVALand updates the task row towaiting_for_approval, so the inbox can find it by querying the database. - Emits an
approval.requestevent with the escalation id, tool name, sanitized arguments and the approver list, and publishes it immediately to the event stream. - Waits on that specific escalation.
The resolution
A human answers by signalling the workflow with the escalation id, an approve or deny boolean, an optional comment, and the resolver’s user id. The signal handler is the authorization point:- If the escalation id is not pending, the signal is ignored.
- If the caller is not permitted to approve, the signal is logged as unauthorized and ignored, so the API or activity boundary cannot bypass the policy.
- Otherwise the escalation is marked resolved and an
approval.responseevent is emitted with the approver’s id. Approve and deny share that one wire type; the decision is in the payload.
200 with {"status": "resolved"} as soon as
the signal is delivered, and signal delivery is all its success value reports —
Temporal signals return no outcome. An unauthorized caller, or one naming an
escalation that is not pending, receives the same 200 resolved as a real
approver while the workflow drops the signal and the task stays in
waiting_for_approval. Confirm an approval by observing the task leaving that
state or by the approval.response event, never by the status code.
On approval the tool call proceeds and the normal tool.call event
follows. On denial a tool message saying the call was denied by a human operator,
with the comment, is appended to the conversation so the model can react rather
than retry blindly. Once no escalations remain pending the workflow returns to
running and the task row is set back to running.
Whether the caller may approve is decided by caller_can_approve:
- An empty approver list means any caller may approve. This is a deliberate
soft default, not an oversight, and it is the state you get from a global
approval rule with no
approversparameter. - A non-empty list is matched literally against
user:<caller_id>.
Why not enforce approval in the interceptor pipeline
AnEscalationGuard exists in the codebase: a gate that reads
execution_state["escalation_rules"] and returns ESCALATE when the action name
matches a glob. It is not registered in the pipeline, and that is intentional.
The interceptor pipeline runs at the Temporal activity boundary. An activity
cannot pause and resume — an ESCALATE there is converted into an
EscalationRequired exception, which fails the activity. The user experience of
that is a failed tool call, not a pending approval, and the conversation state
that would let a human judge the request is not available at that layer.
The workflow is the only place that holds durable state, can wait indefinitely,
can receive a signal, and can put a denial message back into the conversation. So
approval lives there and the guard stays unregistered.
The cost of that choice is coverage: any tool path that does not run through the
agent workflow has no approval flow, only a denial. See the limits below.
Limits
TaskCreate.requires_human_approvalis inert. Described above. It is writable through the API, reaches the workflow goal, and is read by nothing. Per-task approval cannot be requested at task creation; write a policy rule instead.EscalationGuardis dead code today. It is defined, tested and never registered. If you read it and expect glob-matched approval on LLM calls or delegations, nothing wires it up.- The MCP proxy denies rather than escalates.
_authorize_mcp_tool_callstreats anything that is notALLOWas not allowed, so a tool requiring approval called throughPOST /v1/mcp/{instance_id}/mcpreturns HTTP 403 with no escalation created and nothing for a human to answer. An external MCP client cannot obtain approval. SemanticGuardescalation is not an approval either. Its medium-severity patterns returnESCALATEat the activity boundary, which fails the activity. No escalation record is created and no human is asked.- There is no timeout. The workflow waits on the escalation with no deadline.
A task blocked on an approval nobody answers stays in
waiting_for_approvaluntil it is cancelled. There is no auto-deny, no expiry, and no escalation to a second approver. - Empty approvers means anyone. A global approval rule written without an
approversparameter produces an empty list, and an empty list permits any caller to resolve. If you want the approval to mean something, name the approvers. - A rejected approval is indistinguishable from an accepted one over HTTP.
The endpoint returns
200 {"status": "resolved"}on signal delivery, and the workflow drops an unauthorized signal with a log line and no reply. A caller cannot learn from the response whether the approval was refused, whether the escalation id was wrong, or whether it worked. The rejection is recorded only in the worker’s logs. - Group and userset approvers do not grant approval.
group:<id>and<type>:<id>#<relation>references are accepted, validated and stored, but the resolution check only matches directuser:<id>subjects. An approver list containing nothing but group references matches nobody and blocks the task indefinitely. - No notification is sent. Core emits the
approval.requestevent to the stream and updates the task row. It does not send mail, post to a channel, or page anyone. Anything that notifies an approver has to consume the event. - Approval does not change who acts. The task continues to execute as the original run. An approval by a second person does not re-scope the execution to that person’s permissions, and the tool activity itself runs under a system context rather than either user’s.
Related
- Tool authorization — where the
REQUIRE_APPROVALverdict comes from and which paths it reaches. - The policy engine — how approval rules merge across scopes.
- Audit — what an approval leaves behind, and where.