A2A 1.0 · Ed25519 receipts · SOC 2 in progress
Build October 27, 2026Status

Designing a Human-in-the-Loop Approval Gate: JSON Evidence, State Transitions, and the Retry Rules That Keep Them Honest

An approval gate is a state machine with an evidence payload attached, not a modal dialog. This guide covers the states, the JSON schema, the idempotency keys, and the failure modes that decide whether your gate holds under retries.

A reliable human-in-the-loop approval gate design models human intervention as an explicit five-state machine. It avoids paused prompts and open HTTP connections. State must be durable. The gate persists across four non-terminal and terminal states (requested, approved, denied, and expired) before transitioning to a terminal consumed state that locks the approval against double execution.

Production agent systems break when engineers treat approval gates as transient pauses. An agent process crashes mid-wait and drops its execution context. In-memory pauses do not survive restarts. A network partition duplicates an inbound webhook callback, running a mutation twice. An agent re-requests authorization after a human operator denied the action. Building an approval workflow architecture that holds up in production requires an immutable JSON evidence payload, deterministic state transitions, and strict idempotency controls at the storage layer.

The gate is a state machine, not a prompt

A human-in-the-loop approval gate is a durable state record stored in an authoritative database. Prompt instructions telling an agent to ask for permission fail in production. Do not hold connections open. The agent must read its current status at any time using a stable identifier without holding a long-polling socket or an open HTTP connection.

This state-machine architecture eliminates three standard production failures:

  • Premature execution occurs when the agent assumes consent while the request is still pending, or infers permission merely because a denial was not received within an arbitrary client timeout.
  • Denial loops occur when an agent receives a denial, treats the denial as a transient network failure, and immediately generates an identical request that clogs the operator queue.
  • Replay mutations occur when a downstream worker acts multiple times on a single approval because its local retry loop replayed an uncommitted execution step.

The core invariant is simple. The gated action executes if and only if an explicit transition to approved exists for that specific request identifier, and that transition is written to durable storage before any external effect occurs. Demos hide this failure. When an agent script relies on an in-memory event listener while waiting for operator sign-off, a routine container deployment or network blip drops that ephemeral process and orphans the downstream callback. If the human approves the action thirty minutes later, the callback hits an unrouted process or triggers an orphaned execution thread. You can inspect the production schema for managing these states at the AgentDraft approvals endpoint reference.

What belongs in the JSON evidence payload

When an operator evaluates an agent request, the JSON evidence payload constitutes their entire decision context. If critical runtime parameters are missing from this payload, the reviewer is left guessing. Guessed approvals cause silent damage. A guessed approval defeats the purpose of the gate.

Reviewers need raw arguments. They must approve explicit arguments without relying on natural language summaries or vague descriptions of intent. If an agent intends to issue a refund, the payload must specify the targeted customer ID, the payment intent ID, the exact integer amount, and the currency. A production-ready payload requires the following fields:

  • action: A stable, machine-readable verb string namespaced by resource (for example, refund.create or calendar.bump).
  • target: The canonical identifier of the resource being modified.
  • blast_radius: Concrete impact metrics, such as financial amounts in integer cents, rows affected, or permission scopes requested.
  • agent_id: The unique identifier of the requesting agent.
  • trigger_event_id: The inbound event or message ID that caused the agent to take this action.
  • summary: A plain-text summary strictly capped at 120 characters for fast dashboard scanning.
  • args: The complete, raw key-value dictionary the agent will supply to the destination API if authorized.
  • correlation_id: A trace ID linking the approval request, the audit log, and subsequent webhook events across disparate systems.

Consider this standard payload structure submitted by an agent requesting a database migration or high-value mutation:

{
  "request_id": "appr_9824bf20a1",
  "correlation_id": "corr_01HZX87K9B12M",
  "agent_id": "avs_live_agent_billing_prod",
  "action": "stripe.refund.create",
  "target": "ch_3MvICBLkdIwHu7ix0Yx87a11",
  "summary": "Full refund for damaged shipment per ticket #4081",
  "blast_radius": {
    "currency": "usd",
    "amount_cents": 14500
  },
  "args": {
    "charge": "ch_3MvICBLkdIwHu7ix0Yx87a11",
    "amount": 14500,
    "reason": "fraudulent",
    "refund_application_fee": false
  },
  "preconditions": {
    "account_status": "active",
    "max_allowed_drift_seconds": 120
  }
}

The JSON evidence payload must remain immutable once created. Do not update an active request. If the agent recalculates its strategy, detects new inputs, or alters parameters, it must not update an existing request record. Modifying the evidence record while a human is reading it invalidates the review. The agent must cancel or abandon the prior request and open a fresh one with a new identifier.

Payloads must also observe strict size constraints. Keep payloads under 64 KB. Capping payloads at 64 KB keeps storage performant and ensures reviewers can digest the context. If an agent is executing a large operation, such as running a 400-line database migration script, the payload should contain a cryptographic SHA-256 hash of the script alongside an S3 or blob store reference URL. Do not embed the raw SQL text. Omit credentials, raw authorization tokens, and unmasked payment card details from the payload. These records persist in audit databases where data retention rules apply. Within the AgentDraft system, an approval request carries this one-line summary plus the JSON evidence payload; the gated action itself is completely agnostic and does not have to be an operation that AgentDraft natively executes.

Approval workflow architecture: who decides, and where the decision is recorded

A resilient approval workflow architecture enforces a clean separation among three distinct entities: the agent that requests, the human who evaluates, and the system that validates and records. Separate these roles strictly. When systems conflate these roles, such as allowing an agent to manage the state machine or submit the authorization transition itself, the gate collapses.

Human reviews must occur within an authenticated environment. Approvals are decided in the AgentDraft dashboard. AgentDraft emails the workspace owner a notification linking to the queue, but the decision itself is made signed in—there are deliberately no approve-from-email links, because an unauthenticated one-click approve is an attack surface. Slack, Discord, Teams, SMS and push delivery are not available today. One-click URL tokens inside emails are susceptible to enterprise mail pre-fetchers, corporate security scanners that follow links automatically, and simple email forwarding leaks.

Authentication boundaries must remain separate between agents and operators. Humans sign in to the dashboard with a passkey (WebAuthn), with a magic link as the bootstrap and recovery path. Enterprise SSO (SAML/SCIM via WorkOS) is on the AgentDraft roadmap and not available today; agents authenticate with bearer API keys and humans with passkeys. Agent API keys use the prefix avs_live_ and are stored argon2id-hashed inside the backing store. Permissions are scoped strictly: each key requires explicit capabilities, such as availability:read, bookings:write, or mailbox:read. The permission approvals:request is not granted by default; it must be provisioned deliberately for agents requiring gate creation access.

Every lifecycle transition is captured in an append-only log and broadcasts an approval.* webhook event. This enables agent processes to listen asynchronously without wasting compute on continuous polling. The requesting agent decides for itself when to open an approval request. AgentDraft does not yet provide a policy engine that auto-requires approval by action class, amount threshold, or role, and there are no escalation chains or multi-approver quorums—a single workspace human resolves each request.

This design makes a deliberate tradeoff. Having a single workspace human resolve each request is architecturally clean, fast, and simple to reason about, but it creates a single point of absence. When your operational process cannot tolerate a reviewer being offline, configure requests with explicit time-to-live expirations that fail closed so the system does not wait indefinitely for human input. Avoid assuming an escalation path exists when the underlying system intentionally omits one.

State transitions and the status codes that carry them

The state machine governing human-in-the-loop approval gate design consists of five distinct states with bounded transitions. Transitioning to an illegal state must trigger a validation error. The backend must not treat it as a silent no-op.

Initial StateTrigger EventTarget StateHTTP CodeSide Effect
NonePOST /v1/approvalsrequested201 CreatedEmits approval.requested; starts TTL timer.
requestedOperator clicks Approveapproved200 OKEmits approval.approved; records approver ID.
requestedOperator clicks Denydenied200 OKEmits approval.denied; records reviewer note.
requestedClock exceeds expires_atexpired410 GoneEmits approval.expired; request invalidated.
approvedPOST /v1/approvals/:id/consumeconsumed200 OKEmits approval.consumed; locks against replays.
approvedOperator clicks DenyIllegal transition409 ConflictState unchanged; returns transition conflict.
deniedOperator clicks ApproveIllegal transition409 ConflictDenial is terminal; returns transition conflict.

Denials must be final. Denial must be strictly terminal for that specific request identifier. An operator cannot reverse a denial thirty seconds later within the same record. When circumstances change after a rejection, the agent must submit a completely new request carrying a distinct correlation ID, initiating a fresh review cycle and preserving an unambiguous audit trail.

Expiration is an active, explicit state, not an ambiguous absence of information. Every gate request carries an expires_at timestamp. When the TTL expires, the record transitions to expired. Failing closed is mandatory. The agent's control flow must handle expired as an operational branch identical to a denial: it must fail closed and abort the pending operation.

API endpoints managing these transitions must return precise status codes:

  • 404 Not Found indicates that the requested approval ID does not exist in the tenant's workspace, distinguishing missing records from race conditions.
  • 409 Conflict indicates that an operation attempted an invalid state transition, such as trying to approve an already-expired record, or consuming an approval that was already consumed by a parallel worker.
  • 422 Unprocessable Content indicates that the request was syntactically valid JSON, but contained an unsupported action string, violated field lengths, or lacked mandatory parameters.

The agent reads the outcome back by checking the explicit state of the request identifier. An agent must not infer approval simply because no rejection has landed. Furthermore, the consumed state is what makes an approval single-use. Execution must be atomic. The consuming update must be atomic with the execution of the action.

Idempotency: the retry rules that stop a double approval and a double action

Production systems regularly encounter two separate idempotency challenges: duplicate request creation caused by network timeouts during agent retries, and duplicate action execution caused by distributed consumers picking up the same approval event.

To avoid duplicate requests in the dashboard queue, the agent must supply an idempotency key derived from the triggering event and the action verb in an HTTP header:

Idempotency-Key: hash(trigger_event_id + action + target)

Network drops cause duplicate writes. When an agent times out waiting for a 201 Created response, it retransmits the payload with the identical Idempotency-Key header. The server checks the key against existing records. If found, the server bypasses queue creation and returns the existing appr_... resource immediately. This prevents operators from seeing duplicate cards for the exact same action.

For execution, race conditions occur when multiple workers attempt to process an approval notification simultaneously. Developers must not use a naive read-then-write pattern by querying if status equals approved followed by an update to consumed. As detailed in the PostgreSQL Documentation on Transaction Isolation, concurrent read-then-write flows without explicit row locking or serializable isolation allow both threads to read the identical unconsumed state and double-execute the underlying side effect.

Storage-level conditional check-and-set semantics eliminate this race condition. Using the database to enforce single-use execution, an atomic update must assert the prior state during the write. As described in the Amazon DynamoDB Developer Guide for TransactWriteItems, conditional expressions allow applications to enforce strict transactional prerequisites across items:

UPDATE approvals
SET status = 'consumed', consumed_at = NOW()
WHERE id = 'appr_9824bf20a1' 
  AND status = 'approved';

If two workers execute this query concurrently, exactly one worker updates the row and receives an updated row count of 1. The losing worker receives an affected row count of 0, recognizes that the token has already been claimed, and aborts without executing the external side effect.

Outbound webhooks from the gate system operate under at-least-once delivery semantics. Network issues between the server and receiver will trigger automatic retries. Verify the signature first. In accordance with standard HMAC verification patterns outlined by Webhooks.fyi guidelines, incoming webhook payloads must verify a signature over the raw body and a timestamp header.

AgentDraft signs every webhook delivery with an X-AgentDraft-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256> header computed over <t>. followed by the raw request body, using the workspace's signing secret. The receiver verifies it; AgentDraft recommends that receivers reject any timestamp more than 300 seconds (5 minutes) from the current time to block replays, and its documented reference verifier does so. Signed webhook delivery is available on every plan, including the free Developer tier. Handlers must inspect the incoming delivery timestamp, compute the expected HMAC using their pre-shared signing secret, and confirm the match before executing any application logic. You can find reference verifier implementations in the AgentDraft webhook documentation.

Deduplicate on delivery ID. The webhook consumer must deduplicate using the unique delivery_id header. Do not deduplicate by hashing the JSON payload. A valid state machine transition, such as an approval following an expiration reset, can carry identical data properties while representing a distinct lifecycle event. Storing processed delivery_id keys in an atomic cache with a 24-hour TTL protects the consuming logic against at-least-once transport duplicates.

What the audit record must contain for the gate to be defensible

When an unexpected or costly agent action triggers an internal post-mortem, investigators will not accept fuzzy application logs or natural language summaries. Auditors need concrete evidence. The inquiry focuses on three concrete questions: which human user approved the action, what specific parameters were presented on their screen at the moment of review, and when did each state transition occur?

As outlined in NIST SP 800-92 (Guide to Computer Security Log Management), an effective audit record requires explicit timestamps, unique event tracking, and immutable evidence integrity to reconstruct system events reliably. A defensible audit entry must persist these attributes in an append-only store:

  • event_id: An immutable, auto-incrementing or ULID-based record key.
  • request_id: The foreign key tying the entry back to the original approval request.
  • event_type: The exact transition (approval.requested, approval.decided, approval.consumed).
  • actor_type: Either agent or human.
  • actor_id: The API key identifier for an agent or the WebAuthn user ID for a human operator.
  • payload_sha256: The cryptographic digest of the exact JSON evidence payload shown to the user.
  • note: An optional text justification entered by the human reviewer at the moment of decision.
  • timestamp: Nanosecond-resolution UTC time recorded at the database layer.

Payload hashes prevent tampering. Recording the payload_sha256 hash provides cryptographic proof that the parameters executed by the agent match what the operator reviewed. If an engineer modifies an agent's runtime code to alter parameters between approval and dispatch, comparing the executed payload hash against the recorded audit hash will surface the divergence immediately.

Audit retention schedules must be evaluated based on the tier running the workspace. Within AgentDraft, audit retention is per-tier and enforced on read as well as on write, so the retention claim holds even though deletion is lazy. The free Developer tier maintains a 7-day audit retention window, while commercial tiers scale retention across longer periods. AgentDraft does not hold formal compliance certifications (SOC 2, HIPAA, ISO 27001, etc.). It does keep an append-only audit trail. You can review explicit retention windows across each workspace plan at the AgentDraft audit trail page and the AgentDraft security architecture page.

Failure modes to test before you ship the gate

Test these boundaries before shipping. Before putting an approval gate into production, verify your implementation against these specific boundary failures:

  1. To test crash recovery, terminate the agent process immediately after the human clicks Approve, but before the external action dispatches. When the agent process restarts, its initialization loop must query the gate status, detect that the request is approved but unconsumed, and successfully execute the single-use consumption transition.
  2. To test timeouts, configure an agent workflow with a local 60-second execution budget, but set the approval gate TTL to 10 minutes. Intentionally delay human approval until minute two. When the approval webhook finally lands, the agent's consumer must check its workflow state machine, recognize that the broader parent workflow already timed out and aborted, and decline to consume the approval.
  3. To test concurrency, simulate two independent agents attempting to book the same calendar slot or issue a refund for the same invoice. The approval gate evaluates each request independently based on the agent's parameters; it does not deduplicate high-level human intent. Both requests will appear in the operator queue. The reviewer must see the target resource ID highlighted across both requests to reject the duplicate manually.
  4. To test drift, simulate an operator approving a booking request forty minutes after it is opened. In the intervening time, an external customer claimed the target calendar slot. Drift invalidates stale approvals. The agent must re-validate the target state immediately before calling the consume endpoint. If the resource precondition fails, the agent must fail closed and refuse to consume the approval.
  5. To test denial handling, reject an approval request in the dashboard. Confirm that the agent treats an explicit denied status as an unrecoverable exit code. If the agent immediately attempts to submit an identical request using a loop, your monitoring systems should capture and flag that behavior.

Developers testing these edge cases can run validation suites directly against a free Developer workspace. Because signed webhook delivery is fully operational on the Developer tier, you can run automated failure injection tests against live HMAC-verified webhooks without provisioned production cards.

Wiring the gate into a framework you already use

Integrating an approval gate into existing agent orchestration engines requires intercepting execution at the tool level. Intercept calls at the tool wrapper. The LLM must not be the component that decides whether a gate passed. The gate sits within the deterministic code layer of the tool execution wrapper.

In LangChain or the OpenAI Agents SDK, wrap the target tool function. When the model invokes the tool, the wrapper issues an HTTP POST to open the approval request, persists its execution state, and returns a pending sentinel message back to the run loop. The agent suspends execution cleanly. When the system receives the approval.approved webhook, an asynchronous worker wakes up the execution thread, invokes the underlying API using the immutable payload arguments, and marks the approval consumed.

In visual workflow engines like n8n or CrewAI, manage the seam at the task node boundary. Configure the execution graph to split: the agent node sends the request payload to the gate, followed by a wait-for-webhook node listening for the matching approval.approved delivery. The downstream execution branch proceeds only when the HMAC signature matches and the payload's correlation ID matches the parent task context.

For setups leveraging the Model Context Protocol, the gate maps cleanly onto tool pairs. In accordance with the Model Context Protocol specification, tools provide explicit primitives for discovery and invocation. Implement the gate as two separate MCP tools:

  • request_action_approval: Accepts the action verb, target, summary, and argument dictionary. Submits the request to the gate API and returns an approval_id with a status of requested.
  • check_approval_status: A read-only, idempotent polling tool that takes an approval_id and returns the current state (requested, approved, denied, expired, or consumed).

Poll safely using read-only checks. Separating the request call from the status query keeps the check tool completely safe to call within recurring loops. The gated action can be any operation, such as an infrastructure deployment, a schema migration, or a financial refund, and does not need to be an action executed directly by the gate platform.

The gate is only as good as its evidence and its transitions

A reliable human-in-the-loop approval gate design rests on three technical pillars: an immutable, comprehensive JSON evidence payload that eliminates reviewer guesswork; an explicit, five-state lifecycle that treats expirations and denials as terminal barriers; and a check-and-set consumption step that guarantees an approved action executes exactly once.

Test the state machine against real boundary conditions. Open one gated action end to end in a free AgentDraft workspace: the Developer tier needs no card and includes 1 user, 3 agents, one mailbox per workspace (the owner moves it between agents), 50 bookings a month, 7-day audit retention, and booking-linked outbound email only (5 a day). Per-agent inboxes for more than one agent need a paid plan: Individual has 1 mailbox, Team 5, Scale 25, Enterprise unlimited (see https://agentdraft.io/pricing).

Frequently asked questions

How do I set up approvals and versioning for AI agents so changes are tracked?

Track agent updates by decoupling the agent's code version from the approval record. Every approval request must include the requesting agent's identifier, its current Git commit hash or deployment version, and the full JSON evidence payload. Store these attributes inside an append-only audit trail where state transitions are cryptographically hashed and linked to a unique correlation ID. Changes to agent configurations, prompt templates, or model parameters should increment a version tag attached to every subsequent approval payload.

How do I design approval gates for an AI agent workflow?

Design approval gates as deterministic state machines residing in an authoritative database, completely separate from the LLM context window. The gate must support five explicit states: requested, approved, denied, expired, and consumed. Enforce single-use guarantees using conditional storage writes (check-and-set) at the consumption step, bind requests to an explicit TTL, and restrict approvals to an authenticated interface requiring human verification.

Should the agent block and wait for approval, or return and resume later?

The agent should return an execution sentinel and resume later via an asynchronous event or webhook. Resuming asynchronously prevents dropped processes. Holding an HTTP connection, socket, or container thread open while waiting for human review is fragile; human operators take minutes or hours to respond, during which network drops, container recycles, or cloud timeouts will terminate the running process. Suspend the agent workflow by saving its state machine key, then use an approval.approved webhook to trigger workflow resumption.

What should happen when an approval request expires while the agent is still running?

When an approval request reaches its TTL expiration, it must transition to a terminal expired state and fail closed. The agent must treat an expired status identically to an explicit denial: it must cleanly abort the planned mutation, log the expiration event in its local run context, and exit the workflow. The agent must not infer approval or automatically re-request authorization without fresh triggers from its environment.

How do I stop a duplicate approval webhook from executing the gated action twice?

Stop duplicate executions by combining atomic state transitions with webhook delivery deduplication. First, verify the signature header and check the delivery ID against an atomic cache to discard duplicate network packets. Second, execute the gated action only after atomically transitioning the approval status from approved to consumed using a database-level conditional update. When two workers receive identical webhooks simultaneously, the conditional write guarantees that exactly one worker updates the row, while the second worker encounters a row count of zero and aborts execution immediately.