AI agent approval workflows: handling edits and retries WorkOS released Airlock in early access, a tool that requires human approval before a governed AI agent tool call executes and binds that approval to a single request. The workflow stores an immutable request under a unique tenant, user, and operation key via a create_once helper, so a $1,200 refund approval no longer applies if the agent changes the amount or selects another charge. Airlock's current generic call path forwards caller-supplied Accept and Content-Type headers but drops Idempotency-Key, leaving duplicate protection to the application's billing endpoint, which must deduplicate by operation_id. 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.