> For the complete documentation index, see [llms.txt](/llms.txt).
> A full single-fetch corpus is available at [llms-full.txt](/llms-full.txt).
---
title: Build a durable human-approval AI workflow
description: Pause for approval, survive worker restarts, and execute the final side effect exactly once.
tags: ["HITL", "Durability", "Side effects"]
date: 2026-05-13
last_verified: 2026-05-13
audience: both
---

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.

```python
@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.

```json
{
  "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.

```python
@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

- [Retry AI workflow steps without duplicate side effects](/cookbooks/retry-without-duplicate-side-effects.md)
- [Build a customer support agent](/cookbooks/customer-support-agent.md)
- [Build a durable research agent with approval and recovery](/cookbooks/durable-research-agent-approval-recovery.md)
