> 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: Human-in-the-loop
description: Pause workflows for durable human input, approvals, selections, and follow-up decisions.
last_verified: 2026-10-07
---



Human-in-the-loop (HITL) lets a workflow pause mid-execution, wait for a human to respond, then continue from exactly where it left off. The pause is durable. If the worker restarts while waiting, the workflow resumes correctly when the user eventually replies.

Call `ctx.wait_for_user()` Call `ctx.waitForUser()` Call `ctx.AskUser(...)`  anywhere inside a workflow to trigger a pause.

---

## How it works



**Python:**

Call `ctx.wait_for_user()` at any point in your workflow. The workflow pauses there, shows the question to the user, and resumes from that exact point once they respond. The pause survives worker restarts: the run stays paused and continues when the user answers.





**TypeScript:**

Call `ctx.waitForUser()` at any point in your workflow. The workflow pauses there, shows the question to the user, and resumes from that exact point once they respond. The pause survives worker restarts: the run stays paused and continues when the user answers.





**Go:**

Call `ctx.AskUser(request)` at any point in your workflow handler. On first call it saves the request and returns a `*WaitingForUserInputError` — **you must propagate this error up as your workflow's own return error** so the runtime can suspend the run; don't swallow it. When the user responds, AGNT5 replays the workflow function from the top, and this time the same `AskUser` call returns the saved answer directly instead of pausing again.



---

## Answer a pause

A workflow that is waiting for input has the status `paused`, and `agnt5 run` prints that status along with the run ID. People answer in Studio, where the question appears on the run. To answer from code, send the answer to the run:

```bash
curl -X POST 'https://gw.agnt5.com/v1/workflows/resume/<run-id>' \
  -H "Content-Type: application/json" \
  -H "X-API-KEY: agnt5_sk_..." \
  -H "X-DEPLOYMENT-ID: <deployment-id>" \
  -d '{"user_response": "Approve"}'
```

The service key needs the `workflow` scope. Create one with `agnt5 service-keys create --name "approver" --project <project-id> --scopes run,workflow`. Set `X-DEPLOYMENT-ID` to the deployment that runs the workflow. A successful call returns the run ID and `"status": "resumed"`, and the workflow continues from the pause. See [Resume a workflow](/docs/api-reference/runs-and-traces/resume-a-workflow.md) for the full request reference.

---



**Python:**

## `ctx.wait_for_user()` parameters

| Parameter | Type | Default | Description |
|---|---|---|---|
| `question` | `str` | required | The text shown to the user |
| `input_type` | `str` | `"text"` | Input mode (see below) |
| `options` | `list[dict]` | `None` | Choices for `approval`, `select`, and `multiselect`. Each dict needs `"id"` and `"label"` |
| `allow_custom` | `bool` | `False` | Adds a free-text "Something else" option to `select` and `multiselect` |
| `skippable` | `bool` | `False` | Adds a Skip button. Returns `None` when skipped |





**TypeScript:**

## `ctx.waitForUser()` parameters

`waitForUser(question, options?)` — the question is a plain string; everything else is an options object.

| Parameter | Type | Default | Description |
|---|---|---|---|
| `question` | `string` | required | The text shown to the user |
| `options.inputType` | `string` | `'text'` | Input mode (see below) |
| `options.options` | `{ id: string; label: string }[]` | `undefined` | Choices for `approval`, `select`, and `multiselect` |
| `options.allowCustom` | `boolean` | `false` | Adds a free-text "Something else" option to `select` and `multiselect` |
| `options.skippable` | `boolean` | `false` | Adds a Skip button. Returns `null` when skipped |





**Go:**

## `ctx.AskUser()` parameters

`AskUser(request UserInputRequest) (string, error)` — everything is fields on one struct.

| Field | Type | Default | Description |
|---|---|---|---|
| `Prompt` | `string` | required | The text shown to the user |
| `Type` | `HITLInputType` | `HITLText` | Input mode: `HITLText`, `HITLSelect`, `HITLMultiSelect`, `HITLApproval` |
| `Options` | `[]HITLOption` | `nil` | Choices for `HITLApproval`, `HITLSelect`, and `HITLMultiSelect`. Each has `Label` and `Value` |
| `AllowCustom` | `bool` | `false` | Adds a free-text "Something else" option to select/multiselect |
| `Skippable` | `bool` | `false` | Adds a Skip button. Returns `""` when skipped |



---

## Input types



**Python:**

### `text`: free text

The user types any response. Use this for open-ended input like names, instructions, or edited content.

```python
name = await ctx.wait_for_user("What should we call this report?")
```

### `approval`: yes / no

Present a clear action and let the user approve or reject it.

```python
decision = await ctx.wait_for_user(
    question="Deploy to production?",
    input_type="approval",
    options=[
        {"id": "approve", "label": "Approve"},
        {"id": "reject", "label": "Reject"},
    ],
)

if decision == "reject":
    return {"status": "cancelled"}
```

### `select`: pick one

The user picks a single option from a list.

```python
format = await ctx.wait_for_user(
    question="Which output format do you want?",
    input_type="select",
    options=[
        {"id": "pdf", "label": "PDF"},
        {"id": "markdown", "label": "Markdown"},
        {"id": "html", "label": "HTML"},
    ],
)
```

### `multiselect`: pick many

The user picks one or more options. The return value is a JSON-array string of the selected `id`s. Parse it with `json.loads`.

```python
import json

topics = await ctx.wait_for_user(
    question="Which topics should the report cover?",
    input_type="multiselect",
    options=[
        {"id": "market", "label": "Market analysis"},
        {"id": "tech", "label": "Technology trends"},
        {"id": "risk", "label": "Risk factors"},
    ],
)
# topics might be '["market","tech"]'
selected = json.loads(topics)
```





**TypeScript:**

### `text`: free text

The user types any response. Use this for open-ended input like names, instructions, or edited content.

```typescript
const name = await ctx.waitForUser('What should we call this report?');
```

### `approval`: yes / no

Present a clear action and let the user approve or reject it.

```typescript
const decision = await ctx.waitForUser('Deploy to production?', {
  inputType: 'approval',
  options: [
    { id: 'approve', label: 'Approve' },
    { id: 'reject', label: 'Reject' },
  ],
});

if (decision === 'reject') {
  return { status: 'cancelled' };
}
```

### `select`: pick one

The user picks a single option from a list.

```typescript
const format = await ctx.waitForUser('Which output format do you want?', {
  inputType: 'select',
  options: [
    { id: 'pdf', label: 'PDF' },
    { id: 'markdown', label: 'Markdown' },
    { id: 'html', label: 'HTML' },
  ],
});
```

### `multiselect`: pick many

The user picks one or more options. The return value is a JSON-array string of the selected `id`s. Parse it with `JSON.parse`.

```typescript
const topicsRaw = await ctx.waitForUser('Which topics should the report cover?', {
  inputType: 'multiselect',
  options: [
    { id: 'market', label: 'Market analysis' },
    { id: 'tech', label: 'Technology trends' },
    { id: 'risk', label: 'Risk factors' },
  ],
});
// topicsRaw might be '["market","tech"]'
const selected: string[] = topicsRaw ? JSON.parse(topicsRaw) : [];
```





**Go:**

### `HITLText`: free text

The user types any response. Use this for open-ended input like names, instructions, or edited content.

```go
name, err := ctx.AskUser(agnt5.UserInputRequest{Prompt: "What should we call this report?", Type: agnt5.HITLText})
```

### `HITLApproval`: yes / no

Present a clear action and let the user approve or reject it. `ctx.RequestApproval` is a shortcut that returns a `bool` directly instead of an option value.

```go
decision, err := ctx.AskUser(agnt5.UserInputRequest{
    Prompt: "Deploy to production?",
    Type:   agnt5.HITLApproval,
    Options: []agnt5.HITLOption{
        {Label: "Approve", Value: "approve"},
        {Label: "Reject", Value: "reject"},
    },
})

if decision == "reject" {
    return Output{Status: "cancelled"}, nil
}
```

### `HITLSelect`: pick one

The user picks a single option from a list.

```go
format, err := ctx.AskUser(agnt5.UserInputRequest{
    Prompt: "Which output format do you want?",
    Type:   agnt5.HITLSelect,
    Options: []agnt5.HITLOption{
        {Label: "PDF", Value: "pdf"},
        {Label: "Markdown", Value: "markdown"},
        {Label: "HTML", Value: "html"},
    },
})
```

### `HITLMultiSelect`: pick many

The user picks one or more options. The return value is a JSON-array string of the selected `Value`s. Decode it with `json.Unmarshal`.

```go
topics, err := ctx.AskUser(agnt5.UserInputRequest{
    Prompt: "Which topics should the report cover?",
    Type:   agnt5.HITLMultiSelect,
    Options: []agnt5.HITLOption{
        {Label: "Market analysis", Value: "market"},
        {Label: "Technology trends", Value: "tech"},
        {Label: "Risk factors", Value: "risk"},
    },
})
if err != nil {
    return Output{}, err
}
// topics might be `["market","tech"]`
var selected []string
if err := json.Unmarshal([]byte(topics), &selected); err != nil {
    return Output{}, err
}
```




> **Answer wire format.** Every answer reaches the workflow as a string. A skipped question arrives as `__skipped__`, which the SDKs turn into `None`, `null`, or `""` (Python, TypeScript, Go) before returning. A custom entry on `select` arrives as `__custom__:<text>` and the SDKs strip the prefix. A `multiselect` answer is the JSON-array string of the selected ids, and a custom entry is appended inside that array as `"__custom__:<text>"`, so strip the prefix yourself after parsing. The resume API accepts a string, including an empty string, or JSON `null`. Send `null` or the string `__skipped__` to skip. Arrays, objects, numbers, and booleans return HTTP 400.


---

## Options: `allow_custom` and `skippable`



**Python:**

`allow_custom=True` adds a free-text "Something else" field to `select` or `multiselect`.

`skippable=True` adds a Skip button. The return value is `None` when the user skips.

```python
preference = await ctx.wait_for_user(
    question="Pick a tone for the report:",
    input_type="select",
    options=[
        {"id": "formal", "label": "Formal"},
        {"id": "casual", "label": "Casual"},
    ],
    allow_custom=True,
    skippable=True,
)

if preference is None:
    preference = "formal"   # default when skipped
```





**TypeScript:**

`allowCustom: true` adds a free-text "Something else" field to `select` or `multiselect`.

`skippable: true` adds a Skip button. The return value is `null` when the user skips.

```typescript
let preference = await ctx.waitForUser('Pick a tone for the report:', {
  inputType: 'select',
  options: [
    { id: 'formal', label: 'Formal' },
    { id: 'casual', label: 'Casual' },
  ],
  allowCustom: true,
  skippable: true,
});

if (preference === null) {
  preference = 'formal'; // default when skipped
}
```





**Go:**

`AllowCustom: true` adds a free-text "Something else" field to `HITLSelect` or `HITLMultiSelect`.

`Skippable: true` adds a Skip button. The return value is `""` when the user skips.

```go
preference, err := ctx.AskUser(agnt5.UserInputRequest{
    Prompt: "Pick a tone for the report:",
    Type:   agnt5.HITLSelect,
    Options: []agnt5.HITLOption{
        {Label: "Formal", Value: "formal"},
        {Label: "Casual", Value: "casual"},
    },
    AllowCustom: true,
    Skippable:   true,
})

if preference == "" {
    preference = "formal" // default when skipped
}
```



---

## Multiple pauses in one workflow



**Python:**

You can call `ctx.wait_for_user()` as many times as you need. Each call gets its own pause index so the right cached answer is returned on replay.

```python
@workflow
async def review_workflow(ctx: WorkflowContext, draft: str) -> str:
    # First pause: approval
    decision = await ctx.wait_for_user(
        question=f"Approve this draft?\n\n{draft}",
        input_type="approval",
        options=[
            {"id": "approve", "label": "Approve"},
            {"id": "edit", "label": "Edit"},
            {"id": "reject", "label": "Reject"},
        ],
    )

    if decision == "reject":
        return "Rejected."

    if decision == "edit":
        # Second pause: get edited version
        draft = await ctx.wait_for_user(
            question="Paste your revised draft:",
            input_type="text",
        )

    # Third pause: final confirmation before publishing
    confirm = await ctx.wait_for_user(
        question="Publish now?",
        input_type="approval",
        options=[
            {"id": "yes", "label": "Publish"},
            {"id": "no", "label": "Save as draft"},
        ],
    )

    return "Published." if confirm == "yes" else "Saved as draft."
```





**TypeScript:**

You can call `ctx.waitForUser()` as many times as you need. Each call gets its own pause index so the right cached answer is returned on replay.

```typescript
export const reviewWorkflow = workflow(
  'review_workflow',
  async (ctx: Context, input: { draft: string }) => {
    let draft = input.draft;

    // First pause: approval
    const decision = await ctx.waitForUser(`Approve this draft?\n\n${draft}`, {
      inputType: 'approval',
      options: [
        { id: 'approve', label: 'Approve' },
        { id: 'edit', label: 'Edit' },
        { id: 'reject', label: 'Reject' },
      ],
    });

    if (decision === 'reject') {
      return 'Rejected.';
    }

    if (decision === 'edit') {
      // Second pause: get edited version
      draft = (await ctx.waitForUser('Paste your revised draft:', { inputType: 'text' })) ?? draft;
    }

    // Third pause: final confirmation before publishing
    const confirm = await ctx.waitForUser('Publish now?', {
      inputType: 'approval',
      options: [
        { id: 'yes', label: 'Publish' },
        { id: 'no', label: 'Save as draft' },
      ],
    });

    return confirm === 'yes' ? 'Published.' : 'Saved as draft.';
  },
);
```





**Go:**

You can call `ctx.AskUser()` as many times as you need. Each call gets its own pause index so the right cached answer is returned on replay. Propagate any `*WaitingForUserInputError` you get back immediately — don't call `AskUser` again after one pauses.

```go
err := agnt5.RegisterWorkflow(worker, "review_workflow", func(ctx *agnt5.Context, in ReviewInput) (string, error) {
    // First pause: approval
    decision, err := ctx.AskUser(agnt5.UserInputRequest{
        Prompt: "Approve this draft?\n\n" + in.Draft,
        Type:   agnt5.HITLApproval,
        Options: []agnt5.HITLOption{
            {Label: "Approve", Value: "approve"},
            {Label: "Edit", Value: "edit"},
            {Label: "Reject", Value: "reject"},
        },
    })
    if err != nil {
        return "", err
    }

    if decision == "reject" {
        return "Rejected.", nil
    }

    draft := in.Draft
    if decision == "edit" {
        // Second pause: get edited version
        draft, err = ctx.AskUser(agnt5.UserInputRequest{Prompt: "Paste your revised draft:", Type: agnt5.HITLText})
        if err != nil {
            return "", err
        }
    }

    // Third pause: final confirmation before publishing
    confirm, err := ctx.AskUser(agnt5.UserInputRequest{
        Prompt: "Publish now?",
        Type:   agnt5.HITLApproval,
        Options: []agnt5.HITLOption{
            {Label: "Publish", Value: "yes"},
            {Label: "Save as draft", Value: "no"},
        },
    })
    if err != nil {
        return "", err
    }

    if confirm == "yes" {
        return "Published.", nil
    }
    return "Saved as draft.", nil
})
```



---

## Real-world example

A customer submits a refund request. The workflow looks up the order and recommends an amount, then a support agent steps in at up to five points: decide the outcome, enter a custom amount if needed, pick the refund method, choose notification channels, and confirm before money moves.



**Python:**

```python
import json

from agnt5 import workflow, WorkflowContext, function, FunctionContext


# ── Step 1: look up the order and recommend a refund ────────────────────────

@function
async def analyse_refund(ctx: FunctionContext, order_id: str, reason: str) -> dict:
    """Look up the order and produce a refund recommendation."""
    # In practice: fetch order from your database
    return {
        "order_id": order_id,
        "order_total": 149.99,
        "eligible": True,
        "suggested_amount": 149.99,
        "reason": reason,
        "summary": f"Order {order_id}, $149.99, eligible for full refund. Reason: {reason}",
    }


# ── Step 2: Process the approved refund ─────────────────────────────────────

@function
async def process_refund(
    ctx: FunctionContext,
    order_id: str,
    amount: float,
    method: str,
    notify_channels: list[str],
) -> dict:
    """Issue the refund and send notifications."""
    # In practice: call your payments API and notification service
    ctx.logger.info(f"Refund processed: ${amount} via {method} for order {order_id}")
    return {
        "status": "refunded",
        "order_id": order_id,
        "amount": amount,
        "method": method,
        "notified_via": notify_channels,
    }


# ── Workflow ─────────────────────────────────────────────────────────────────

@workflow
async def refund_approval_workflow(
    ctx: WorkflowContext,
    order_id: str,
    reason: str,
) -> dict:

    # Stage 1: AI reviews the request
    analysis = await ctx.step(analyse_refund, order_id, reason)

    if not analysis["eligible"]:
        return {"status": "ineligible", "order_id": order_id}

    # ── HITL pause 1: approve / modify / reject ──────────────────────────────
    decision = await ctx.wait_for_user(
        question=(
            f"{analysis['summary']}\n\n"
            f"Suggested refund: ${analysis['suggested_amount']}\n\n"
            "What would you like to do?"
        ),
        input_type="select",
        options=[
            {"id": "approve", "label": "Approve full refund"},
            {"id": "modify",  "label": "Approve with a different amount"},
            {"id": "reject",  "label": "Reject refund"},
        ],
    )

    if decision == "reject":
        return {"status": "rejected", "order_id": order_id}

    # ── HITL pause 2: custom amount (only if modifying) ──────────────────────
    refund_amount = analysis["suggested_amount"]

    if decision == "modify":
        raw = await ctx.wait_for_user(
            question=f"Enter the refund amount (order total: ${analysis['order_total']}):",
            input_type="text",
        )
        refund_amount = float(raw)

    # ── HITL pause 3: pick refund method ─────────────────────────────────────
    method = await ctx.wait_for_user(
        question="How should the refund be returned to the customer?",
        input_type="select",
        options=[
            {"id": "original",     "label": "Original payment method"},
            {"id": "store_credit", "label": "Store credit"},
            {"id": "bank",         "label": "Bank transfer"},
        ],
    )

    # ── HITL pause 4: pick notification channels ──────────────────────────────
    channels_raw = await ctx.wait_for_user(
        question="Notify the customer via:",
        input_type="multiselect",
        options=[
            {"id": "email", "label": "Email"},
            {"id": "sms",   "label": "SMS"},
            {"id": "push",  "label": "Push notification"},
        ],
    )
    notify_channels = json.loads(channels_raw) if channels_raw else ["email"]

    # ── HITL pause 5: final confirmation before money moves ───────────────────
    confirm = await ctx.wait_for_user(
        question=(
            f"Ready to process:\n"
            f"  Order:   {order_id}\n"
            f"  Amount:  ${refund_amount}\n"
            f"  Method:  {method}\n"
            f"  Notify:  {', '.join(notify_channels)}\n\n"
            "Confirm?"
        ),
        input_type="approval",
        options=[
            {"id": "confirm", "label": "Confirm & Process"},
            {"id": "cancel",  "label": "Cancel"},
        ],
    )

    if confirm == "cancel":
        return {"status": "cancelled", "order_id": order_id}

    # Stage 2: process the refund
    result = await ctx.step(process_refund, order_id, refund_amount, method, notify_channels)
    return result
```





**TypeScript:**

```typescript
import { fn, workflow } from '@agnt5/sdk';
import type { Context } from '@agnt5/sdk';

// ── Step 1: AI analyses the refund request ──────────────────────────────────

const analyseRefund = fn('analyse_refund').run(
  async (ctx: Context, input: { orderId: string; reason: string }) => {
    // In practice: fetch order from your database
    return {
      orderId: input.orderId,
      orderTotal: 149.99,
      eligible: true,
      suggestedAmount: 149.99,
      reason: input.reason,
      summary: `Order ${input.orderId}, $149.99, eligible for full refund. Reason: ${input.reason}`,
    };
  },
);

// ── Step 2: Process the approved refund ─────────────────────────────────────

const processRefund = fn('process_refund').run(
  async (
    ctx: Context,
    input: { orderId: string; amount: number; method: string; notifyChannels: string[] },
  ) => {
    // In practice: call your payments API and notification service
    ctx.logger.info(`Refund processed: $${input.amount} via ${input.method} for order ${input.orderId}`);
    return {
      status: 'refunded',
      orderId: input.orderId,
      amount: input.amount,
      method: input.method,
      notifiedVia: input.notifyChannels,
    };
  },
);

// ── Workflow ─────────────────────────────────────────────────────────────────

export const refundApprovalWorkflow = workflow(
  'refund_approval_workflow',
  async (ctx: Context, input: { orderId: string; reason: string }) => {
    // Stage 1: AI reviews the request
    const analysis = await analyseRefund(ctx, { orderId: input.orderId, reason: input.reason });

    if (!analysis.eligible) {
      return { status: 'ineligible', orderId: input.orderId };
    }

    // ── HITL pause 1: approve / modify / reject ──────────────────────────────
    const decision = await ctx.waitForUser(
      `${analysis.summary}\n\nSuggested refund: $${analysis.suggestedAmount}\n\nWhat would you like to do?`,
      {
        inputType: 'select',
        options: [
          { id: 'approve', label: 'Approve full refund' },
          { id: 'modify', label: 'Approve with a different amount' },
          { id: 'reject', label: 'Reject refund' },
        ],
      },
    );

    if (decision === 'reject') {
      return { status: 'rejected', orderId: input.orderId };
    }

    // ── HITL pause 2: custom amount (only if modifying) ──────────────────────
    let refundAmount = analysis.suggestedAmount;

    if (decision === 'modify') {
      const raw = await ctx.waitForUser(
        `Enter the refund amount (order total: $${analysis.orderTotal}):`,
        { inputType: 'text' },
      );
      refundAmount = parseFloat(raw ?? '0');
    }

    // ── HITL pause 3: pick refund method ─────────────────────────────────────
    const method = await ctx.waitForUser('How should the refund be returned to the customer?', {
      inputType: 'select',
      options: [
        { id: 'original', label: 'Original payment method' },
        { id: 'store_credit', label: 'Store credit' },
        { id: 'bank', label: 'Bank transfer' },
      ],
    });

    // ── HITL pause 4: pick notification channels ──────────────────────────────
    const channelsRaw = await ctx.waitForUser('Notify the customer via:', {
      inputType: 'multiselect',
      options: [
        { id: 'email', label: 'Email' },
        { id: 'sms', label: 'SMS' },
        { id: 'push', label: 'Push notification' },
      ],
    });
    const notifyChannels: string[] = channelsRaw ? JSON.parse(channelsRaw) : ['email'];

    // ── HITL pause 5: final confirmation before money moves ───────────────────
    const confirm = await ctx.waitForUser(
      `Ready to process:\n  Order:   ${input.orderId}\n  Amount:  $${refundAmount}\n  Method:  ${method}\n  Notify:  ${notifyChannels.join(', ')}\n\nConfirm?`,
      {
        inputType: 'approval',
        options: [
          { id: 'confirm', label: 'Confirm & Process' },
          { id: 'cancel', label: 'Cancel' },
        ],
      },
    );

    if (confirm === 'cancel') {
      return { status: 'cancelled', orderId: input.orderId };
    }

    // Stage 2: process the refund
    return processRefund(ctx, {
      orderId: input.orderId,
      amount: refundAmount,
      method: method ?? 'original',
      notifyChannels,
    });
  },
);
```





**Go:**

```go
import (
    "github.com/agnt5dev/sdk-go/agnt5"
    "context"
    "encoding/json"
    "strconv"
    "strings"
)

// ── Step 1: AI analyses the refund request ──────────────────────────────────

func analyseRefund(ctx *agnt5.Context, orderID, reason string) (RefundAnalysis, error) {
    // In practice: fetch order from your database
    return RefundAnalysis{
        OrderID:          orderID,
        OrderTotal:       149.99,
        Eligible:         true,
        SuggestedAmount:  149.99,
        Reason:           reason,
        Summary:          "Order " + orderID + ", $149.99, eligible for full refund. Reason: " + reason,
    }, nil
}

// ── Step 2: Process the approved refund ─────────────────────────────────────

func processRefund(ctx *agnt5.Context, orderID string, amount float64, method string, notifyChannels []string) (RefundResult, error) {
    // In practice: call your payments API and notification service
    ctx.Logger().Info("Refund processed", "amount", amount, "method", method, "order_id", orderID)
    return RefundResult{Status: "refunded", OrderID: orderID, Amount: amount, Method: method, NotifiedVia: notifyChannels}, nil
}

// ── Workflow ─────────────────────────────────────────────────────────────────

err := agnt5.RegisterWorkflow(worker, "refund_approval_workflow", func(ctx *agnt5.Context, in RefundInput) (RefundResult, error) {
    // Stage 1: AI reviews the request
    analysis, err := agnt5.Step(ctx, "analyse_refund", func(context.Context) (RefundAnalysis, error) {
        return analyseRefund(ctx, in.OrderID, in.Reason)
    })
    if err != nil {
        return RefundResult{}, err
    }
    if !analysis.Eligible {
        return RefundResult{Status: "ineligible", OrderID: in.OrderID}, nil
    }

    // ── HITL pause 1: approve / modify / reject ──────────────────────────────
    decision, err := ctx.AskUser(agnt5.UserInputRequest{
        Prompt: analysis.Summary + "\n\nSuggested refund: $" + fmt.Sprint(analysis.SuggestedAmount) + "\n\nWhat would you like to do?",
        Type:   agnt5.HITLSelect,
        Options: []agnt5.HITLOption{
            {Label: "Approve full refund", Value: "approve"},
            {Label: "Approve with a different amount", Value: "modify"},
            {Label: "Reject refund", Value: "reject"},
        },
    })
    if err != nil {
        return RefundResult{}, err
    }
    if decision == "reject" {
        return RefundResult{Status: "rejected", OrderID: in.OrderID}, nil
    }

    // ── HITL pause 2: custom amount (only if modifying) ──────────────────────
    refundAmount := analysis.SuggestedAmount
    if decision == "modify" {
        raw, err := ctx.AskUser(agnt5.UserInputRequest{
            Prompt: "Enter the refund amount (order total: $" + fmt.Sprint(analysis.OrderTotal) + "):",
            Type:   agnt5.HITLText,
        })
        if err != nil {
            return RefundResult{}, err
        }
        refundAmount, err = strconv.ParseFloat(raw, 64)
        if err != nil {
            return RefundResult{}, err
        }
    }

    // ── HITL pause 3: pick refund method ─────────────────────────────────────
    method, err := ctx.AskUser(agnt5.UserInputRequest{
        Prompt: "How should the refund be returned to the customer?",
        Type:   agnt5.HITLSelect,
        Options: []agnt5.HITLOption{
            {Label: "Original payment method", Value: "original"},
            {Label: "Store credit", Value: "store_credit"},
            {Label: "Bank transfer", Value: "bank"},
        },
    })
    if err != nil {
        return RefundResult{}, err
    }

    // ── HITL pause 4: pick notification channels ──────────────────────────────
    channelsRaw, err := ctx.AskUser(agnt5.UserInputRequest{
        Prompt: "Notify the customer via:",
        Type:   agnt5.HITLMultiSelect,
        Options: []agnt5.HITLOption{
            {Label: "Email", Value: "email"},
            {Label: "SMS", Value: "sms"},
            {Label: "Push notification", Value: "push"},
        },
    })
    if err != nil {
        return RefundResult{}, err
    }
    notifyChannels := []string{"email"}
    if channelsRaw != "" {
        if err := json.Unmarshal([]byte(channelsRaw), &notifyChannels); err != nil {
            return RefundResult{}, err
        }
    }

    // ── HITL pause 5: final confirmation before money moves ───────────────────
    confirmed, err := ctx.RequestApproval(
        "Ready to process:\n  Order:   "+in.OrderID+"\n  Amount:  $"+fmt.Sprint(refundAmount)+"\n  Method:  "+method+"\n  Notify:  "+strings.Join(notifyChannels, ", ")+"\n\nConfirm?",
        nil,
    )
    if err != nil {
        return RefundResult{}, err
    }
    if !confirmed {
        return RefundResult{Status: "cancelled", OrderID: in.OrderID}, nil
    }

    // Stage 2: process the refund
    return agnt5.Step(ctx, "process_refund", func(context.Context) (RefundResult, error) {
        return processRefund(ctx, in.OrderID, refundAmount, method, notifyChannels)
    })
})
```



What each pause does:

| Pause | Input type | Purpose |
|---|---|---|
| 1 | `select` | Support agent decides: full refund, modified amount, or reject |
| 2 | `text` | Agent enters a custom amount (only shown when modifying) |
| 3 | `select` | Agent picks the refund method |
| 4 | `multiselect` | Agent picks which channels to notify the customer on |
| 5 | `approval` | Final review before the refund is actually processed |

---

## Under the hood: replay behaviour

This replay model is the same across all three SDKs.

When `ctx.wait_for_user()` When `ctx.waitForUser()` When `ctx.AskUser()`  is called for the first time, the workflow saves its state and stops. When the user responds, AGNT5 runs the workflow function again **from the very top**. The code before the pause runs again, and this time the call finds the saved answer and returns it immediately without pausing.

Run work that should not repeat, such as an API call or a notification, as a step. Once the runtime commits a step's completion, the step returns its saved result when the workflow runs again instead of doing the work again. If the worker fails before that completion is committed, the step runs again, so pass the step's idempotency key to any external system it calls. See [making steps idempotent](/docs/build/workflows.md#ctxstep-checkpoint-your-own-code).

```
First run:   step(generate_draft) → wait for user → pauses ⏸
On resume:   step(generate_draft) returns its saved result → wait for user → returns answer → step(publish) ✅
```



**Python:**

```python
@workflow
async def publish_workflow(ctx: WorkflowContext, topic: str) -> str:
    draft = await ctx.step(generate_draft, topic)   # its committed result is reused on resume

    decision = await ctx.wait_for_user(
        question=f"Approve this draft?\n\n{draft}",
        input_type="approval",
        options=[
            {"id": "approve", "label": "Approve"},
            {"id": "discard", "label": "Discard"},
        ],
    )

    if decision == "discard":
        return "Discarded."

    return await ctx.step(publish, draft)
```





**TypeScript:**

```typescript
export const publishWorkflow = workflow(
  'publish_workflow',
  async (ctx: Context, input: { topic: string }) => {
    // its committed result is reused on resume
    const draft = await ctx.step('generate_draft', () => generateDraft(ctx, { topic: input.topic }));

    const decision = await ctx.waitForUser(`Approve this draft?\n\n${draft}`, {
      inputType: 'approval',
      options: [
        { id: 'approve', label: 'Approve' },
        { id: 'discard', label: 'Discard' },
      ],
    });

    if (decision === 'discard') {
      return 'Discarded.';
    }

    return ctx.step('publish', () => publish(ctx, { draft }));
  },
);
```





**Go:**

```go
err := agnt5.RegisterWorkflow(worker, "publish_workflow", func(ctx *agnt5.Context, in PublishInput) (string, error) {
    // its committed result is reused on resume
    draft, err := agnt5.Step(ctx, "generate_draft", func(context.Context) (string, error) {
        return generateDraft(ctx, in.Topic)
    })
    if err != nil {
        return "", err
    }

    decision, err := ctx.AskUser(agnt5.UserInputRequest{
        Prompt: "Approve this draft?\n\n" + draft,
        Type:   agnt5.HITLApproval,
        Options: []agnt5.HITLOption{
            {Label: "Approve", Value: "approve"},
            {Label: "Discard", Value: "discard"},
        },
    })
    if err != nil {
        return "", err
    }
    if decision == "discard" {
        return "Discarded.", nil
    }

    return agnt5.Step(ctx, "publish", func(context.Context) (string, error) {
        return publish(ctx, draft)
    })
})
```




> **Rule:** run side effects as steps, and call the pause directly in the workflow.


---

## Edge cases



**Python:**

**User skips the question**

When `skippable=True`, the return value is `None` if the user clicks Skip. Always handle `None` explicitly to avoid a `TypeError` later.

```python
note = await ctx.wait_for_user(
    question="Any special instructions?",
    input_type="text",
    skippable=True,
)

instructions = note or "No special instructions."
```

**User enters unexpected text in a `text` input**

`wait_for_user()` returns whatever the user typed as a plain string. If you need a number or a specific format, validate and convert it yourself:

```python
raw = await ctx.wait_for_user("Enter the refund amount (numbers only):")

try:
    amount = float(raw)
except ValueError:
    return {"status": "error", "message": f"Invalid amount: {raw}"}
```

**Conditional pause: pause only sometimes**

`wait_for_user()` can be inside an `if` block. The pause index is tracked per call that actually executes, so conditional pauses work correctly across replays.

```python
decision = await ctx.wait_for_user(
    question="Approve or modify?",
    input_type="select",
    options=[
        {"id": "approve", "label": "Approve"},
        {"id": "modify",  "label": "Modify amount"},
    ],
)

if decision == "modify":
    # This pause only runs when the agent chose "modify"
    raw = await ctx.wait_for_user("Enter the new amount:")
    amount = float(raw)
```

**Side effects before a pause**

If you need to send a notification or call an external API before the pause, run it as a step. Once the runtime commits the step's completion, a replay returns the saved result instead of calling the API again. If the worker fails before that completion is committed, the step runs again, so read `ctx.activation.idempotency_key` inside the step function and pass it to the downstream system as its idempotency key. See [making steps idempotent](/docs/build/workflows.md#ctxstep-checkpoint-your-own-code).

```python
await ctx.step(send_slack_alert, f"Refund request {order_id} needs review.")

decision = await ctx.wait_for_user("Approve refund?", input_type="approval", options=[...])
```





**TypeScript:**

**User skips the question**

When `skippable: true`, the return value is `null` if the user clicks Skip. Always handle `null` explicitly to avoid a `TypeError` later.

```typescript
const note = await ctx.waitForUser('Any special instructions?', {
  inputType: 'text',
  skippable: true,
});

const instructions = note || 'No special instructions.';
```

**User enters unexpected text in a `text` input**

`waitForUser()` returns whatever the user typed as a plain string (or `null`). If you need a number or a specific format, validate and convert it yourself:

```typescript
const raw = await ctx.waitForUser('Enter the refund amount (numbers only):');
const amount = Number(raw);

if (Number.isNaN(amount)) {
  return { status: 'error', message: `Invalid amount: ${raw}` };
}
```

**Conditional pause: pause only sometimes**

`waitForUser()` can be inside an `if` block. The pause index is tracked per call that actually executes, so conditional pauses work correctly across replays.

```typescript
const decision = await ctx.waitForUser('Approve or modify?', {
  inputType: 'select',
  options: [
    { id: 'approve', label: 'Approve' },
    { id: 'modify', label: 'Modify amount' },
  ],
});

if (decision === 'modify') {
  // This pause only runs when the agent chose "modify"
  const raw = await ctx.waitForUser('Enter the new amount:');
  const amount = Number(raw);
}
```

**Side effects before a pause**

If you need to send a notification or call an external API before the pause, run it as a step. Once the runtime commits the step's completion, a replay returns the saved result instead of calling the API again. If the worker fails before that completion is committed, the step runs again, so read `ctx.activation?.idempotencyKey` inside the step and pass it to the downstream system as its idempotency key. See [making steps idempotent](/docs/build/workflows.md#ctxstep-checkpoint-your-own-code).

```typescript
await ctx.step('slack_alert', () => sendSlackAlert(`Refund request ${orderId} needs review.`));

const decision = await ctx.waitForUser('Approve refund?', { inputType: 'approval', options: [] });
```





**Go:**

**User skips the question**

When `Skippable: true`, the return value is `""` if the user clicks Skip. Always handle the empty string explicitly.

```go
note, err := ctx.AskUser(agnt5.UserInputRequest{Prompt: "Any special instructions?", Type: agnt5.HITLText, Skippable: true})

instructions := note
if instructions == "" {
    instructions = "No special instructions."
}
```

**User enters unexpected text in a `HITLText` input**

`AskUser` returns whatever the user typed as a plain string. If you need a number or a specific format, validate and convert it yourself:

```go
raw, err := ctx.AskUser(agnt5.UserInputRequest{Prompt: "Enter the refund amount (numbers only):", Type: agnt5.HITLText})
if err != nil {
    return Output{}, err
}

amount, err := strconv.ParseFloat(raw, 64)
if err != nil {
    return Output{Status: "error", Message: "Invalid amount: " + raw}, nil
}
```

**Conditional pause: pause only sometimes**

`AskUser` can be inside an `if` block. The pause index is tracked per call that actually executes, so conditional pauses work correctly across replays.

```go
decision, err := ctx.AskUser(agnt5.UserInputRequest{
    Prompt: "Approve or modify?",
    Type:   agnt5.HITLSelect,
    Options: []agnt5.HITLOption{
        {Label: "Approve", Value: "approve"},
        {Label: "Modify amount", Value: "modify"},
    },
})

if decision == "modify" {
    // This pause only runs when the agent chose "modify"
    raw, err := ctx.AskUser(agnt5.UserInputRequest{Prompt: "Enter the new amount:", Type: agnt5.HITLText})
    amount, err := strconv.ParseFloat(raw, 64)
}
```

**Side effects before a pause**

If you need to send a notification or call an external API before the pause, run it as a step. Once the runtime commits the step's completion, a replay returns the saved result instead of calling the API again. If the worker fails before that completion is committed, the step runs again, so call `agnt5.ActivationFromContext(stepCtx)` inside the step closure, read its `IdempotencyKey`, and pass it to the downstream system as its idempotency key. See [making steps idempotent](/docs/build/workflows.md#ctxstep-checkpoint-your-own-code).

```go
if _, err := agnt5.Step(ctx, "slack_alert", func(context.Context) (string, error) {
    return "", sendSlackAlert(orderID + " needs review.")
}); err != nil {
    return Output{}, err
}

decision, err := ctx.AskUser(agnt5.UserInputRequest{Prompt: "Approve refund?", Type: agnt5.HITLApproval})
```



## Next steps

- [Workflows](/docs/build/workflows.md): compose steps, parallel work, and durable sleep around your pauses.
- [Tools](/docs/build/tools.md#human-in-the-loop-tools): let an agent pause for approval from inside its tool loop.
- [Local development](/docs/build/local-development.md): trigger a workflow and answer its pauses from Studio.
