Skip to content
Blog

Consent Flows: Human Approval at the Point of Action

Design consent flows that pause an agent before it acts: deciding which tools need approval, the pause-and-resume state machine, sticky decisions, rejection messages, server-side approval security, and how to build the same pattern on the Gemini function calling loop.

Published on • October 10, 2026

AI Assistant

An agent that can refund an order, delete a record, or send an email to ten thousand people needs a brake pedal. Prompting it to “be careful” is not a control — the model is probabilistic, the tool is real, and the side effect happens the moment the function executes. The control has to live at the boundary where the decision becomes an action.

That boundary is the consent flow: a mechanism that pauses execution when an agent proposes a sensitive tool call, surfaces the specific call to a human, and resumes only after an explicit approve or reject. It is the single highest-leverage safety pattern in agent engineering, and it is more subtle to build correctly than it looks — because the hard parts are not the dialog, they are state serialization, identity, and what happens when nobody answers.

In this tutorial, you will learn how to:

  • Classify tools by whether they need approval, always, never, or conditionally
  • Implement the pause → persist → decide → resume state machine
  • Make approval decisions durable across processes and restarts
  • Write rejection messages that the model can actually act on
  • Secure a server-side approval endpoint against forged and replayed decisions
  • Build the same pattern on the Gemini function calling loop
  • Decide when not to pause

Key technologies: OpenAI Agents SDK (needs_approval, RunState, ToolApprovalItem), Gemini API function calling (Interactions API), RunContext, idempotency, session auth.

Prerequisites

  • An agent framework with a tool-calling loop and some form of interruptible run state
  • A place to store paused runs (database, queue, or object store)
  • A UI surface where a human can see a proposed action and click approve or reject

The core question: which tools need approval?

Every consent flow starts with a classification, and the classification is a product decision, not a technical one. Walk your tool list and bucket each entry:

ClassCriteriaExamples
NeverRead-only, reversible, no external side effectsearch_docs, get_weather, read_file
AlwaysIrreversible or externally visibleissue_refund, delete_account, send_bulk_email
ConditionallyDepends on the argumentssend_email is safe for one recipient, not for a list of 10,000
DeferredEffects are real but time-boundedschedule_job, create_ticket

The conditionally class is where the design gets interesting. A boolean flag is not enough — you need a predicate that receives the parsed arguments and returns a decision:

@tool(needs_approval=True)                      # always
async def cancel_order(order_id: int) -> str:
    ...

async def requires_review(_ctx, params, _call_id) -> bool:   # conditional
    return "refund" in params.get("subject", "").lower()

@tool(needs_approval=requires_review)
async def send_email(subject: str, body: str) -> str:
    ...

Two properties of that predicate matter:

It receives the arguments, not just the tool name. Approval is a property of the call, not the function. The same send_email is routine or dangerous depending on who is in the recipient list.

It must fail closed. If the framework cannot safely inspect the arguments — missing, empty, malformed JSON, valid JSON but not an object, non-standard constants like NaN or Infinity — do not invoke the predicate. Treat the call as requiring manual approval. A predicate that silently returns False when it cannot parse its input turns an unparseable payload into an unauthorized action.

That fail-closed rule is the single most important line in this post. Every other decision is a tradeoff; this one is not.

The state machine

A consent flow is four states and two transitions:

RUNNING ──(tool call needs approval)──▶ PENDING
PENDING ──(human approves)────────────▶ RUNNING ──▶ ...
PENDING ──(human rejects)─────────────▶ RUNNING ──▶ (tool returns rejection text)

Concretely:

  1. The model emits a tool call. The runner evaluates its approval rule.
  2. If a decision for that call already exists, proceed without prompting.
  3. If approval is required and no decision exists, pause. The run result now carries a list of pending approval items with the agent name, tool name, and arguments.
  4. Convert the paused result into serializable run state, hand the items to a human, and apply approve() or reject() against that state.
  5. Resume the run with the original top-level agent. It continues where it left off, and re-enters this flow if the next call also needs approval.

Three details from step 3 through 5 that teams routinely get wrong:

Approval is run-wide, not local. A pending approval raised by a nested sub-agent — reached through a handoff, or through an agent invoked as a tool — still surfaces on the outer run. You approve it against the outer state and resume the original top-level agent. Resuming the nested agent instead produces a run that has lost its context.

You do not have to resolve every pending item at once. A single pause can contain a mix of function tools, MCP tool approvals, and nested-agent approvals. If you resume after resolving only some of them, the resolved calls continue and the unresolved ones pause the run again on the next turn.

Decisions must be scoped to the specific call. Keying approvals by tool name means approving send_email once implicitly approves every subsequent send_email. Key by call ID. If you want the “approve this tool for the rest of the run” behaviour, make it an explicit opt-in:

state.approve(interruption, always_approve=True)   # sticky, scoped to this run
state.approve(interruption, always_approve=False)  # this call only

Sticky decisions stored in the run state survive serialization — state.to_json() / RunState.from_json(...) and state.to_string() / RunState.from_string(...) — so a paused run resumed hours later keeps its prior approvals.

Making the pause durable

In a CLI or a notebook, you can prompt synchronously with input(). In production, approval happens in a different process, on a different device, possibly hours later. That forces the paused state to be serializable.

result = await Runner.run(agent, task)

while result.interruptions:
    # Persist the paused run.
    state = result.to_state()
    store.put(run_id, state.to_json())

    # ... hours later, possibly in another process ...

    state = await RunState.from_json(agent, store.get(run_id))
    for item in state.get_interruptions():
        if human_approved(item):
            state.approve(item, always_approve=False)
        else:
            state.reject(item)

    result = await Runner.run(agent, state)

The serialized payload contains execution state: approval decisions, pending tool calls, tool arguments, usage, and trace metadata. That has two consequences you must design around:

Treat it as sensitive data. Tool arguments may contain PII or secrets the agent passed in. Encrypt at rest, restrict access, and set a retention window.

Treat it as executable state. Deserializing a snapshot restores control flow. Never deserialize a snapshot you received from a client — more on this below.

If approvals may sit for hours or days, also store a version marker for your agent definitions or SDK alongside the state. Otherwise a resumed run can hit a prompt or tool schema that changed underneath it and fail at deserialization time rather than at a point where you can handle it gracefully.

A consent dialog that says “Tool update_record wants to run — approve?” is useless. The human needs enough context to make the call, and the arguments are untrusted display content:

  • What — tool name plus a human-readable summary of the arguments, rendered as text and escaped if it goes anywhere near HTML.
  • Why — what the agent was trying to accomplish. Pull the current step or plan from the run, not the raw chain of thought.
  • Blast radius — recipients, record count, monetary amount, whether it is reversible.
  • Who — the agent that proposed it, and the user whose session it belongs to.

Give the reviewer a reject path with a reason. Feeding that reason back to the model turns a rejection into a correction:

def format_rejection(args) -> str | None:
    if args.kind != "approval_rejected":
        return None
    return "Publish action was canceled because approval was rejected."

run_config = RunConfig(tool_error_formatter=format_rejection)

# Per-call override takes precedence over the run-wide formatter.
state.reject(
    interruption,
    rejection_message="Publish action was canceled because the reviewer denied approval.",
)

There are two layers: a run-wide tool_error_formatter that sets the default model-visible message, and a per-call rejection_message for a specific decision. The per-call value wins. Both are worth setting — the run-wide one as a sane default, the per-call one when the reviewer’s reason is specific enough to help.

Securing the approval endpoint

This is where most implementations are quietly broken. The pattern is almost always “the browser sends approve: true with a decision ID, the server applies it.” Four checks, in order, all server-side:

1. Authenticate the reviewer using your own session or auth middleware. Never take the reviewer’s identity from the approval request body. A body field named user_id is a suggestion, not a credential.

2. Authorize that reviewer for this run and these pending calls. Possession of a run ID or decision ID is not authorization. A reviewer who can guess or leak an ID must not be able to approve another team’s run.

3. Validate against server-owned state. Load the snapshot from your storage, read the pending items from it, and check the submitted decision IDs and booleans against those. Do not accept replacement tool calls, arguments, approval records, or serialized state from the client. The client submits {decision_id, approved} — nothing else.

4. Make consumption atomic. Concurrent or replayed submissions must not resume the same snapshot twice. Use an atomic owner-checked transition in storage before starting resumed execution.

Two additional rules that are easy to get wrong:

Escaping. Tool names and arguments are untrusted display content. Escape them when rendering HTML — a tool argument containing markup is an injection vector in your reviewer UI.

Serialization is not authentication. Replacing the context with an override, setting strict_context=True, or deleting the approval records from a client-supplied snapshot does not make it safe. Other fields still control resumed execution. If a snapshot ever travels through a client, you must verify integrity, bind it to the authorized user and run, and prevent replay before deserializing — and even then you have not encrypted it or hidden its contents.

For browser and mobile approval UIs, keep the complete snapshot in application-controlled server storage. Send the reviewer only the tool details they are authorized to see, plus opaque identifiers for the pending decisions.

Building the same pattern on Gemini

The consent flow is not specific to one SDK. On the Gemini API the shape differs, because the model hands you a function call rather than a pausable run.

The function calling loop has four steps: define a function declaration, send it to the model, execute the function yourself, and send the result back for a final response. The model never executes the function — your application does. That is the insertion point.

With the Interactions API, a run produces a sequence of steps, and function calls arrive as function_call steps you can inspect before executing:

interaction = client.interactions.create(
    model="gemini-3.8-flash",
    input="Refund order 4471 for $240.",
    tools=[refund_function],
)

for step in interaction.steps:
    if step.type == "function_call":
        # Consent gate lives here, before execution.
        if requires_consent(step.name, step.arguments):
            decision = await ask_human(step.name, step.arguments)
            if not decision.approved:
                step.respond_with_rejection(
                    "The reviewer denied the refund. Explain to the user "
                    "that the refund was not approved and stop."
                )
                continue
        result = await execute(step.name, step.arguments)
        step.respond(result)

The mechanics are simpler than the run-state version — there is no serialization because you are in the request loop — but the policy is identical: fail closed on unparseable arguments, scope decisions to the call, and return a rejection message the model can act on rather than an empty error.

The important structural difference: with automatic function calling enabled, the SDK executes tools for you and the consent gate disappears. If you need approval, disable automatic execution for that tool set and handle the loop yourself. An approval gate you cannot reach because a helper library already ran the function is not a gate.

When not to pause

Consent has a cost, and applying it uniformly produces approval fatigue — the state where reviewers click “approve” without reading, which is strictly worse than no gate at all.

Do pause for irreversible, externally visible, or high-volume actions. Refunds, deletions, outbound comms, infrastructure changes, anything crossing a trust boundary.

Do not pause for read-only calls. Asking a human to approve search_docs teaches them to stop reading the dialog.

Do not pause on a hot path the user is already authorizing. If the user just typed “refund my order,” the confirmation is the text box they typed it into. Gate on scope instead — a $5 refund the user asked for directly can auto-approve while a $5,000 one cannot.

Batch thoughtfully. A run that pauses five times in a row trains the reviewer to click through. Where the framework allows it, collect pending approvals and present them together, with the resolution order preserved.

A useful heuristic for tuning: track the approve rate per tool. A tool sitting at 100% approval for weeks is either correctly gated or gated too aggressively — check whether it should be moving to conditionally, and demote it. A tool at 50% means your classification is doing real work.

A checklist before you ship

  • Approval predicates fail closed on unparseable or missing arguments
  • Decisions are keyed by call ID, not tool name
  • Sticky approvals are an explicit opt-in and are scoped to a single run
  • Paused state is serialized, encrypted at rest, and has a retention limit
  • Resuming uses the original top-level agent, not the nested one
  • The reviewer UI escapes tool arguments as untrusted content
  • The approval endpoint authenticates, authorizes, validates, and consumes atomically
  • Rejection reasons are fed back to the model as model-visible text
  • Read-only tools are not gated
  • Approve rates are instrumented so you can retune the classification

Further reading