> 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-07-24
---



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 state is saved and nothing is lost.





**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 state is saved and nothing is lost.





**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.



---



**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 comma-separated string of the selected `id`s.

```python
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 = topics.split(",")
```





**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 comma-separated string of the selected `id`s.

```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 = topicsRaw ? topicsRaw.split(',') : [];
```





**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 comma-separated string of the selected `Value`s.

```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"},
    },
})
// topics might be "market,tech"
selected := strings.Split(topics, ",")
```



---

## 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. An AI agent reviews it and produces a recommendation. A support agent then steps in at four points: decide the outcome, adjust the amount if needed, pick the refund method, and confirm before money moves.



**Python:**

```python
from agnt5 import Agent, workflow, WorkflowContext, function, FunctionContext
from agnt5.lm import BuiltInTool


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

@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 ──────────────────────────────
    if not ctx._is_replay:
        ctx.logger.info("Waiting for support agent decision...")

    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 = channels_raw.split(",") 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
// ── 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 = channelsRaw ? channelsRaw.split(',') : ['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
    // Runs again on resume — keep this idempotent (e.g. dedupe in the log sink)
    ctx.logger.info('Draft ready, waiting for approval...');

    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 publish(ctx, { draft });
  },
);
```





**Go:**

The Go SDK does not expose an `IsReplay`-style flag either. Keep side effects before a pause idempotent (e.g. upsert instead of insert, or check-then-act) rather than relying on a guard, since that code path runs again on every resume.

```go
err := agnt5.RegisterWorkflow(worker, "publish_workflow", func(ctx *agnt5.Context, in PublishInput) (string, error) {
    draft, err := generateDraft(ctx, in.Topic)
    if err != nil {
        return "", err
    }

    // Runs again on resume — keep this idempotent (e.g. dedupe in the log sink)
    ctx.Logger().Info("Draft ready, waiting for approval...")

    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 publish(ctx, draft)
})
```



<Callout type="tip">**Rule:** Guard side effects, never guard the pause itself.</Callout>

---

## 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 that must not repeat**

If you need to send a notification or call an external API before the pause, wrap it in `if not ctx._is_replay:` so it only fires on the first pass:

```python
if not ctx._is_replay:
    await 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 that must not repeat**

If you need to send a notification or call an external API before the pause, make the call idempotent (e.g. key it by `orderId` so a duplicate send is a no-op) — there's no `ctx.isReplay` guard to rely on yet:

```typescript
await sendSlackAlert(`Refund request ${orderId} needs review.`); // must be idempotent

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 that must not repeat**

If you need to send a notification or call an external API before the pause, make the call idempotent (e.g. key it by `orderID` so a duplicate send is a no-op) — there's no replay guard to rely on:

```go
sendSlackAlert(orderID + " needs review.") // must be idempotent

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


