Skip to content
Docs
Cookbooks Build a durable human-approval AI workflow
May 13, 2026 HITLDurabilitySide effects

Build a durable human-approval AI workflow

Pause for approval, survive worker restarts, and execute the final side effect exactly once.

Human approval is the clearest demo of durable execution. The workflow starts, does useful AI work, waits for a person, survives for hours or days, and resumes when an approval signal arrives.

Scenario

A support agent drafts a refund response and prepares a refund request. The business rule is simple: the AI can draft and recommend, but a human must approve before money moves.

What you build

  • A workflow that drafts an action with an agent.
  • A durable approval pause.
  • A signal that records approve, reject, or request-changes.
  • A final side effect that executes once.
  • A trace that shows the full decision path.

Workflow shape

The workflow separates recommendation from execution.

@workflow
async def refund_review(ctx: WorkflowContext, ticket_id: str) -> RefundOutcome:
    ticket = await ctx.step(load_ticket, ticket_id)
    customer = await ctx.step(load_customer, ticket.customer_id)
    recommendation = await ctx.step(draft_refund_recommendation, ticket, customer)

    decision = await ctx.wait_for_signal(
        "refund_decision",
        timeout="7d",
        metadata={"ticket_id": ticket.id, "amount": recommendation.amount},
    )

    if decision.status != "approved":
        return RefundOutcome(status="not_approved", reason=decision.reason)

    receipt = await ctx.step(issue_refund_once, ticket.id, recommendation.amount)
    return RefundOutcome(status="refunded", receipt_id=receipt.id)

The pause is workflow state, not process memory. The worker can restart while the workflow is waiting.

Approval payload

Keep the approval signal explicit. Do not pass free-form text as the only decision record.

{
  "status": "approved",
  "reviewer_id": "user_123",
  "reason": "Customer is inside the refund window.",
  "approved_amount": 4900
}

The trace should preserve the recommendation, the reviewer, the decision, and the final side effect receipt.

Side-effect guard

The final step should be idempotent. Use a key derived from the workflow run and the business object.

@function
async def issue_refund_once(ticket_id: str, amount: int) -> RefundReceipt:
    idempotency_key = f"refund:{ticket_id}:{amount}"
    return await stripe.refunds.create(
        payment_intent=lookup_payment(ticket_id),
        amount=amount,
        idempotency_key=idempotency_key,
    )

Production checks

  • Restart the worker while the workflow is waiting.
  • Send the approval after the restart.
  • Confirm the workflow resumes from the waiting point.
  • Confirm duplicate approval signals do not create duplicate refunds.
  • Confirm rejected decisions stop before the side-effect step.

Next steps

© 2026 AGNT5
llms.txt