# AI agent approval workflows: handling edits and retries

> Source: <https://workos.com/blog/ai-agent-approval-policies-airlock>
> Published: 2026-09-15 15:02:39+00:00

# AI agent approval workflows: handling edits and retries

Add human approval to AI agent tool calls. Learn how to review exact requests, resume execution, and handle edits and retries with Airlock.

Approving an agent's $1,200 refund should authorize that refund to that customer. If the agent changes the amount or selects another charge before execution, the original approval should no longer apply. This guide walks through an AI agent approval workflow that binds a human decision to one tool call and resumes execution after approval.

[WorkOS Airlock](https://workos.com/airlock) can require human approval before a governed agent call proceeds. It is [available in early access](https://workos.com/airlock). The example below shows how your application can preserve the proposed action, resume it after approval, and recover when the provider's response is uncertain.

## Save the action the reviewer will approve

Consider a support agent handling duplicate-charge tickets. Configure a policy that requires billing approval above $500. For a $1,200 refund, the reviewer needs the customer, charge, amount, currency, and billing evidence. They are approving a particular transaction.

This example routes calls through Airlock to a billing endpoint your application owns. Connect it to Airlock and enable enforcement and the required runtime policy. The refund amount is inside the request body, so do not assume it becomes a fixed numeric constraint automatically. A hard ceiling can also be enforced by your billing service.

The Python-like code uses application helpers, not Airlock SDK methods. `create_once` atomically stores an immutable request under a unique tenant, user, and operation key, returns the existing matching record on restart, and rejects reuse of that key with different request data. New records start in `ready`. The context comes from authenticated server state and retains the original intent and session; `BILLING_REFUNDS_URL` is your configured billing endpoint.

``` python
def create_refund(ctx, refund_id):
    return create_once(
        key=(ctx.tenant, ctx.user, refund_id),
        context=ctx,
        request={
            "method": "POST",
            "url": BILLING_REFUNDS_URL,
            "body": {
                "operation_id": refund_id,
                "charge": "charge-example-17",
                "amount_cents": 120000,
                "currency": "usd",
            },
        },
    )
```

Use a globally unique ID for each logical refund. The billing endpoint must durably deduplicate by `operation_id` and reject changed payloads for the same ID. This is application behavior you implement. Airlock's current generic call path forwards caller-supplied `Accept` and `Content-Type` headers; it drops `Idempotency-Key`. If your billing service calls Stripe, that service supplies the provider key and follows [Stripe's idempotency rules](https://docs.stripe.com/api/idempotent_requests). Adding the header to the agent's Airlock request does not establish duplicate protection.

Show the saved transaction and linked charge records to the reviewer. Let Airlock validate the configured approver's identity and authority; an agent-supplied approver name is not sufficient.

## Resume the saved tool call after approval

An approval grants a one-time pass for a matching request under the same organization, user, and intent. The client then retries the saved call. Clicking approve does not execute the refund. On restart, inspect the saved approval before sending anything.

``` python
def advance_refund(key, ctx):
    with operation_lock(key):
        op = load_owned(key, ctx)
        if op.state not in ("ready", "pending"):
            return

        if not original_context_valid(op, ctx):
            stop_for_new_authorization(op)
            return

        if op.approval_id is not None:
            status = read_approval(op, ctx)
            if status != "approved":
                if status in (
                    "denied", "canceled", "expired",
                ):
                    stop_attempt(op, status)
                return

        send_once(op, ctx)
```

The operation lock permits one worker to advance this record at a time. `load_owned` checks the caller's tenant and user. `original_context_valid` requires the original, still-valid intent and session. A new intent needs fresh policy evaluation; it cannot spend the old intent's approval. Keep the billing operation ID when arranging a new authorization attempt.

`read_approval` normalizes the approval status; a pending status or failed status lookup sends nothing. A denied, canceled, or expired approval stops this attempt. A local polling timeout leaves the pending record intact. Even an approved status is provisional: Airlock checks expiry, identity, the request fingerprint, and whether the pass is unspent when the call is retried. Concurrent retries cannot both consume that pass.

If the amount or charge changes, create a separately reviewed proposal. Do not overwrite the saved request or attach its approval to the changed call. Editing an ordinary policy also does not necessarily revoke an already granted pass; the pass can survive that change.

## Handle the result without submitting the refund twice

A call can be authorized and still fail to return a confirmed provider result. Airlock can return an upstream failure response, and a client can lose the connection after the provider accepts the request. Handle both possibilities on the initial submission and on an approved retry:

``` python
def send_once(op, ctx):
    mark_in_flight(op)  # Durable before sending.
    try:
        result = submit_via_airlock(
            op, ctx,
            approval_id=op.approval_id,
        )
    except TransportFailure:
        mark_unknown_and_reconcile(op)
        return

    if result.kind == "needs_approval":
        save_pending(op, result.approval_id)
    elif result.kind == "not_forwarded":
        stop_attempt(op, result.reason)
    elif result.kind == "completed":
        save_completion(op, result.provider_id)
    else:
        mark_unknown_and_reconcile(op)
```

`submit_via_airlock` sends the saved method, URL, body, and permitted headers with the original intent context and, when present, the saved approval ID. It never retries automatically. Its normalized result names are application conventions: `needs_approval` records the returned approval; `not_forwarded` means Airlock definitively refused this submission before forwarding it; `completed` requires confirmation of the specific refund from the billing service. An allow verdict or an HTTP success status alone is insufficient.

Transport failures, upstream errors, unrecognized responses, and unconfirmed provider results go to reconciliation. The durable `in_flight` state also prevents a restarted worker from blindly resubmitting after a crash. A recovery worker must inspect the billing operation's status; it must not create another refund. If the outcome remains unknown, leave the operation unresolved.

A consumed approval cannot be spent again. Any later write attempt must obtain authorization and use the same billing operation ID when retrying the same logical refund. Provider idempotency can have a retention window, so it does not replace your operation record or reconciliation.

Test pending, denied, canceled, expired, and consumed approvals; changed requests and intents; concurrent workers; and a crash after the provider accepts the refund. Verify both the authorization outcome and the billing record.

The [Agent Night demo](https://workos.com/blog/agent-night-recap-airlock-intent-based-access-control) shows approval and client resume with an email to an unfamiliar distribution list. The recording below shows that workflow.

[Request Airlock early access](https://workos.com/airlock) to add human approval to your agents' actions.
