Blog · · 10 min read
Human in the loop AI agents: build approvals that survive restarts

Human in the loop AI agents break when the approval screen and the execution state drift apart. The screen may still say "Approve" after the process that created the request has restarted. The tool arguments may have changed. Two callbacks may race to resume the same run. A button click cannot resolve any of those conditions, and treating it as permission to execute can produce stale or duplicate work.
Approval needs its own durable workflow. Store the exact proposed action, bind each decision to an authenticated approver and an immutable action version, and represent rejection or modification as a state change. Resume through an idempotent execution path. This keeps the decision valid across restarts, retries, timeouts, and user edits.
Why a confirmation modal is not an approval workflow
The word "approve" answers only whether an action may proceed. The application still has to answer the operational questions around that decision:
- Which exact action and arguments did the person review?
- Was that person authorized to approve it for this user and tenant?
- Is the decision still valid after policy, data, or price changes?
- What happens when the approver rejects, edits, ignores, or submits twice?
- Can another worker resume the run after the original process disappears?
- How does the application prove that the approved side effect ran once?
Frameworks provide useful pause-and-resume primitives, but the product owns the decision. OpenAI's Agents SDK documents serializing pending run state so an application can store long-running approvals in a database or queue and restore them later. LangGraph interrupts persist graph state and wait for an explicit resume. Both features preserve computation. Product authorization, action versions, expiry rules, and audit history still belong in application code.
Practitioners run into this missing layer quickly. A LangGraph ApprovalNode feature request describes repeated custom work around interrupt() and Command(resume=...), especially for approve, reject, and modify behavior. The request points to a broader implementation need: one stable application contract above the runtime-specific pause mechanism.
Model human in the loop AI agents as durable workflows
Use an explicit state machine. Avoid deriving status from chat text such as "the user said yes." A useful minimum has these states:
proposed
-> awaiting_approval
-> rejected
-> expired
-> superseded
-> approved
-> executing
-> succeeded
-> failed
-> unknown
superseded matters when a person modifies a proposal. The edited action is a new version, not an in-place mutation of the approved payload. That rule prevents an old decision from authorizing new arguments.
Keep three records with distinct responsibilities. Run state tracks the user's goal, current step, budgets, and final outcome. Action state records one proposed tool call, its normalized arguments, effects, and execution result. Approval state records who may decide, what they decided, when they decided, and which action version that decision covers.
A run can then survive several approval cycles without rewriting its history. Support staff can inspect a rejected action without mistaking it for the approved replacement that followed.
Persist the contract before you pause
Create the action and approval request before notifying the UI or pausing the runtime. After a restart, the persisted record is the authority. One workable schema is:
approval_request:
id: apr_123
tenant_id: tenant_42
run_id: run_900
action_id: act_781
action_version: 2
tool_name: issue_refund
normalized_arguments:
order_id: ord_55
amount_minor: 7500
currency: USD
reason: damaged_item
arguments_hash: sha256_of_normalized_arguments
effect_class: financial_write
requested_by_user_id: user_18
allowed_approver_rule: finance_manager_for_tenant
policy_version: refund_policy_12
status: awaiting_approval
expires_at: 2026-07-16T18:00:00Z
created_at: 2026-07-16T17:30:00Z
The model may propose tool_name and arguments. Application code supplies the tenant, authenticated user, approver rule, policy version, effect class, expiry, and normalized hash. Prompt content cannot choose its own approver or scope.
Store enough context to render a clear review screen, but keep credentials, secrets, and unrestricted prompt history out of the approval record. The reviewer should see the target object, changed fields, expected effect, relevant policy result, and any irreversible consequence. A decision should never depend on access to hidden model reasoning.
Restate's approval pattern demonstrates waiting for an external signal and resuming after a process restart. Persist the action before waiting. The later signal should point to stored state rather than carry the only copy of that state.
Implement the decision path in seven steps
1. Normalize and validate the proposal
Resolve defaults, canonicalize identifiers, apply policy limits, and validate the action against current product state. Compute the arguments hash after normalization. The approver must review the same representation that execution will receive.
If validation changes a meaningful field, show the normalized version to the approver. Never display one amount and execute another because a hidden adapter applied a default later.
2. Determine whether approval is required
Use application policy rather than model confidence. Inputs can include the actor, tenant, tool, effect class, amount, data sensitivity, destination, and current risk conditions. Return a structured result such as allow, deny, require_approval, or modify.
An automatic allow can continue through the same action ledger used by approved work. A deny becomes a typed run outcome. require_approval creates the record above. A policy-level modify produces a new normalized proposal before any human decision.
3. Publish the approval request after commit
Write the action, approval request, and outbox record in one database transaction. The outbox later publishes the UI notification or workflow signal. Otherwise, a crash can leave the UI showing a request that was never stored, or it can store a request without notifying anyone.
The client can present the approval through an inbox, product screen, email link, or chat surface. Microsoft's AG-UI human-in-the-loop documentation shows a client receiving an approval request, collecting a decision, and sending the response back so the run can resume. Keep the visible client replaceable by making the server-side request and decision APIs authoritative.
4. Record a decision with compare-and-set rules
Require an authenticated decision endpoint to carry the approval ID, expected action version, and a unique decision ID. Then verify tenant membership, approver policy, status, version, and expiry in one transaction.
def decide(approval_id, decision_id, expected_version, actor, choice, edits=None):
approval = lock_approval(approval_id)
assert actor_can_approve(actor, approval.allowed_approver_rule)
assert approval.tenant_id == actor.tenant_id
assert approval.action_version == expected_version
if decision_exists(decision_id):
return prior_decision(decision_id)
if approval.status != "awaiting_approval":
raise Conflict("approval is already resolved")
if now() >= approval.expires_at:
mark_expired(approval)
raise Conflict("approval expired")
return apply_choice(approval, decision_id, actor, choice, edits)
When the same submission arrives twice, return the recorded decision instead of starting another resume. A late submission cannot turn an expired request back into an approved one. If two different decisions race, return a visible conflict to the losing request.
5. Treat modification as a new proposal
When the reviewer changes an amount, destination, date, recipient, or other execution field, mark the old request superseded. Create action version 2 with a new arguments hash. Run validation and policy again.
The same person may approve some edits immediately. Other changes need a different role or another review. Reducing a refund may stay within the current manager's authority, while changing its destination account should trigger a fresh security check. Put those distinctions in policy. An edited proposal is not automatically safer.
6. Resume through a durable dispatch
Recording approved and resuming the runtime happen in separate operations. Connect them with a transactional outbox or a durable workflow signal. On redelivery, the runtime must recognize that this approval ID and action version already resumed.
Temporal's human-in-the-loop example uses signals for approval and durable timers for deadlines. The example also waits without keeping a worker busy. Temporal, Restate, LangGraph, OpenAI's SDK, and ordinary product queues expose different mechanics, but each implementation needs a durable resume message and a deduplication key.
7. Execute the exact approved action once
Before calling the tool, reload the approved action by ID and version. Recompute or verify its hash. Use a stable idempotency key such as action_id:version for the external side effect. Never reconstruct executable arguments from the latest conversation.
Write the result as succeeded, failed, or unknown. Use unknown when the connection failed after the remote system may have committed. Reconcile an uncertain write before retrying it.
Work through a refund modification
Suppose an agent proposes a USD 125 refund for order ord_55. Product policy requires a finance manager's approval above USD 100.
- The application normalizes the proposal and stores action version 1 for USD 125.
- It creates approval
apr_123, bound to version 1, the tenant's finance-manager rule, and a 30-minute expiry. - A manager reviews the order and changes the refund to USD 75.
- The server records the edit decision, marks version 1 and its approval as superseded, and creates action version 2.
- Policy reevaluates version 2. If the manager may approve that amount, the server records approval for version 2. Otherwise it routes the new proposal according to the current rule.
- A durable outbox event resumes the run with
act_781:2. - The execution adapter sends
act_781:2as its idempotency key and stores the provider result.
If the old browser tab later submits "approve" for version 1, the decision endpoint returns a conflict. If two workers receive the resume event for version 2, the action ledger and idempotency key prevent a second refund. The system can explain both outcomes from stored records rather than from reconstructed chat history.
Handle rejection, timeout, cancellation, and restarts
Rejection
Record a reason code and optional reviewer note. Route the run to a declared outcome such as needs_revision, denied, or needs_human. Do not ask the agent to reinterpret a rejection as permission to find a slightly different tool path.
Timeout
Expiry changes stored state. A UI label alone does not stop a late callback. Use a durable timer or scheduled job that compares the current status and version before marking the request expired. Temporal's example includes rejection and timeout handling, while the Restate pattern shows a durable timeout competing with the approval signal. Product policy decides whether expiry cancels the run, returns it to a queue, or requests a fresh proposal.
Cancellation
A user may cancel the overall run while an approval is pending. Mark the request cancelled or superseded and prevent later decisions from resuming it. If execution has already started, cancellation cannot promise that a remote side effect stopped. Return the real state and begin compensation when the action contract supports it.
Runtime restart
Persist the runtime's resumable state or enough typed application state to reconstruct the next step. OpenAI documents RunState serialization for long-running approvals, and LangGraph documents interrupts backed by persistence. LangGraph also warns that a resumed node starts again from its beginning, so code before an interrupt must be safe to repeat or moved into a separate node. Keep irreversible work after the approval boundary and behind an idempotent action ledger.
Verify the approval system before shipping
Run these tests against persisted infrastructure rather than only a local agent console:
- Stop the runtime after creating the approval, start a different worker, and approve the request.
- Submit the same decision ID twice and prove that only one resume event is effective.
- Submit approve and reject concurrently and confirm that one atomic transition wins while the other receives a conflict.
- Modify a material field and confirm that the old approval cannot authorize the new version.
- Approve one second after expiry and confirm that execution does not begin.
- Remove the approver's role between request and decision and confirm that authorization is checked again.
- Cross tenant IDs in the URL, token, and stored request and verify that every mismatch fails.
- Restart immediately before and after outbox publication and prove that the resume is neither lost nor applied twice.
- Drop the tool response after the external system commits and confirm that the action becomes
unknownrather than being blindly repeated. - Cancel the run while approval is pending and confirm that a later callback cannot revive it.
- Trace one approval ID through proposal, policy, decision, resume, action execution, and final product outcome.
Test the review copy too. The approver should understand the target, changed values, effect, deadline, and available choices without reading internal logs. Durable storage cannot compensate for an interface that hides what will happen.
Choose the runtime without moving product policy into it
Framework support removes some plumbing. OpenAI supplies resumable run state and tool approval decisions. LangGraph supplies persisted interrupts and review or edit patterns. Temporal supplies durable workflows, signals, and timers. Restate supplies durable execution and external promises. Whichever runtime you choose, keep the product contract the same.
Keep action identity, tenant and user authorization, immutable versions, policy decisions, audit history, and idempotent side effects in application-owned records. Then a runtime migration changes adapters rather than changing what an approval means.
Start with one sensitive tool. Define its action schema, approval states, approver rule, expiry, modification behavior, idempotency key, and recovery owner. Write the restart, stale-version, duplicate-decision, and uncertain-side-effect tests before enabling the tool. Human in the loop AI agents become safer when approval is a persisted execution contract, not a temporary question on a screen.
References
- OpenAI Agents SDK: Human-in-the-loop supports tool approval decisions, pause and resume, state serialization, and long-running approval storage.
- LangGraph: Interrupts supports persisted interrupts, explicit resume, approve or reject branches, review and edit patterns, and restart behavior inside interrupted nodes.
- Temporal: Human-in-the-loop AI agent supports signal-based approval, durable timers, resource-efficient waiting, rejection, and timeout handling.
- Restate: Approvals with pause and resume supports external approval signals, durable waiting, process-restart recovery, and timeout outcomes.
- Microsoft Agent Framework: Human-in-the-loop with AG-UI supports the client and server interaction used to display an approval request, collect a decision, and resume a run.
- LangGraph issue #8026: ApprovalNode feature request supplies practitioner evidence for reusable approve, reject, modify, pause, and resume patterns.