---
title: Human in the loop
description: Let the agent ask you questions, wait for your approval before it submits, hand you the browser for a CAPTCHA, and let you take over at any time.
---

Some steps need a person: a one-time code sent to your phone, a decision before a purchase, a CAPTCHA. The agent pauses the run, tells you what it needs, and continues when you reply. Your code (or a person using it) answers through one endpoint, `POST /v1/browser/runs/{id}/reply`.

| The run needs | `needs.kind` | You reply with |
| --- | --- | --- |
| Information it doesn't have | `question` | `{"answer": "…"}` or `{"secret": "…"}` |
| Your OK before it submits | `approval` | `{"approve": true}` or `{"approve": false, "reason": "…"}` |
| A person in the browser | `takeover` | Finish in Live View, then `{"done": true}` or `{"failed": true}` |

## How a pause works

When the agent needs you, the run's `status` becomes `waiting_for_human` and `needs` describes what it needs (abridged):

```json
{
  "id": "brun_5b7d9f1a3c5e7a9b1d3f5a7c",
  "object": "browser_run",
  "status": "waiting_for_human",
  "steps": 6,
  "current_url": "https://bank.example.com/verify",
  "needs": {
    "kind": "question",
    "prompt": "What is the 6-digit code sent to your phone ending 42?",
    "expects_secret": true,
    "screenshot": "/v1/browser/runs/brun_5b7d9f1a3c5e7a9b1d3f5a7c/screenshot",
    "since": "2026-10-11T09:31:12.000Z",
    "reply": "/v1/browser/runs/brun_5b7d9f1a3c5e7a9b1d3f5a7c/reply"
  }
}
```

| Field | Meaning |
| --- | --- |
| `needs.kind` | `question`, `approval` or `takeover` |
| `needs.prompt` | What the agent needs, in one sentence, ready to show a person |
| `needs.screenshot` | Path of a JPEG of the page when the pause began. Fetch it with your API key. |
| `needs.since` | When the pause began |
| `needs.reply` | Path to reply to |

While a run waits:

- The browser stays open on the same page, so the agent continues exactly where it stopped.
- A `?wait=` status request returns at once instead of holding. Reply, or poll with your own delay.
- If you set a [webhook](/docs/browser-agent/webhooks), it receives a `run.waiting` event.
- Each pause includes one minute. Beyond that, waiting costs $0.005 per minute ([pricing](/docs/browser-agent/modes#time-and-waiting)).
- If nobody replies within `human_timeout_minutes` (default 30), the run ends as `timed_out` with the error `no human response within 30 minutes`.

## Handle pauses in your code

This loop runs a task that may ask for approval or a code, and asks the person at the terminal:

```python title="Python"
import os
import requests

API = "https://lovelyrouter.com/v1/browser"
H = {"Authorization": f"Bearer {os.environ['LOVELY_API_KEY']}"}

run = requests.post(f"{API}/runs", headers=H, json={
    "goal": "In the rewards shop, redeem 500 points for the Blue Mug and tell me the order number.",
    "start_url": "https://shop.example.com/rewards",
    "profile": "shop-login",
    "approve": "risky",
}).json()

while run["status"] in ("running", "waiting_for_human"):
    if run["status"] == "waiting_for_human":
        needs = run["needs"]
        if needs["kind"] == "approval":
            print(needs["prompt"], *needs["fields"], sep="\n  ")
            reply = {"approve": input("Approve? [y/N] ").strip().lower() == "y"}
        elif needs["kind"] == "question":
            value = input(needs["prompt"] + " ")
            reply = {"secret": value} if needs["expects_secret"] else {"answer": value}
        else:  # takeover
            print(needs["prompt"], "\nOpen:", needs["live_view_url"])
            input("Press Enter when you are done. ")
            reply = {"done": True}
        # A 409 here means the run already moved on; the next status request shows where it is.
        requests.post(f"{API}/runs/{run['id']}/reply", headers=H, json=reply)
    run = requests.get(f"{API}/runs/{run['id']}", headers=H, params={"wait": 30}).json()

print(run["status"], run["answer"])
```

```js title="Node.js"
import readline from 'node:readline/promises';

const API = 'https://lovelyrouter.com/v1/browser';
const headers = {
  Authorization: `Bearer ${process.env.LOVELY_API_KEY}`,
  'content-type': 'application/json',
};
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });

let run = await fetch(`${API}/runs`, {
  method: 'POST',
  headers,
  body: JSON.stringify({
    goal: 'In the rewards shop, redeem 500 points for the Blue Mug and tell me the order number.',
    start_url: 'https://shop.example.com/rewards',
    profile: 'shop-login',
    approve: 'risky',
  }),
}).then((r) => r.json());

while (run.status === 'running' || run.status === 'waiting_for_human') {
  if (run.status === 'waiting_for_human') {
    const { needs } = run;
    let reply;
    if (needs.kind === 'approval') {
      console.log(needs.prompt, '\n  ' + needs.fields.join('\n  '));
      reply = { approve: (await rl.question('Approve? [y/N] ')).trim().toLowerCase() === 'y' };
    } else if (needs.kind === 'question') {
      const value = await rl.question(needs.prompt + ' ');
      reply = needs.expects_secret ? { secret: value } : { answer: value };
    } else {
      console.log(needs.prompt, '\nOpen:', needs.live_view_url);
      await rl.question('Press Enter when you are done. ');
      reply = { done: true };
    }
    // A 409 here means the run already moved on; the next status request shows where it is.
    await fetch(`${API}/runs/${run.id}/reply`, { method: 'POST', headers, body: JSON.stringify(reply) });
  }
  run = await fetch(`${API}/runs/${run.id}?wait=30`, { headers }).then((r) => r.json());
}
rl.close();
console.log(run.status, run.answer);
```

For a person who isn't at a terminal, send them `needs.prompt` (and the screenshot or Live View link) through your own app, chat or email, and post their reply when it arrives. A [webhook](/docs/browser-agent/webhooks) tells you when to.

## Answer a question

The agent asks when the page needs something the goal doesn't contain: a verification code sent to your phone or email, a missing detail, or a choice only you can make. Reply in one of two ways:

- **`{"answer": "…"}`** — text the agent reads and uses, up to 2,000 characters. Use it for details and choices: `{"answer": "Use the billing address, not the shipping one."}`
- **`{"secret": "…"}`** — a value the agent types into the page without ever seeing it, up to 4,096 characters. Use it for one-time codes: `{"secret": "481923"}`

`needs.expects_secret` is `true` when the question asks for a code, password, PIN or one-time code. A secret reply can be typed only on the site the run was on when it asked, into any text-like field there, password and PIN fields included. Like other secrets, a value of six characters or more is replaced with `[secret:<name>]` wherever it would appear in the run's output, and the value is deleted when the run ends. For passwords you use on every run, store them in the [vault](/docs/browser-agent/logins) instead, so the agent doesn't have to ask.

```bash title="cURL"
curl https://lovelyrouter.com/v1/browser/runs/brun_5b7d9f1a3c5e7a9b1d3f5a7c/reply \
  -H "Authorization: Bearer $LOVELY_API_KEY" \
  -H "content-type: application/json" \
  -d '{"secret": "481923"}'
```

For codes from an authenticator app, store the TOTP seed in the vault instead. The agent then types the current code itself and doesn't need to ask. See [Two-factor codes](/docs/browser-agent/logins#two-factor-codes).

## Approve before submitting

`approve` decides when the agent stops for your OK before an action that submits something: a button that submits a form, a button labelled as a purchase, booking, payment, message or deletion ("Buy now", "Book", "Pay", "Send", "Delete" and similar), or pressing Enter in a field of a form that isn't a search box, which submits that form. Links are never paused on: following one only opens a page.

| `approve` | The agent pauses before |
| --- | --- |
| `risky` (default) | Those actions when it judges them irreversible or consequential: a payment or purchase, an order or booking, sending a message or email, deleting data, submitting an application. Logins, searches, navigation and intermediate form steps go ahead. |
| `submit` | Every one of those actions. |
| `none` | Nothing. The agent submits on its own. |

Any other value is rejected with `400`. An approval pause looks like this:

```json
{
  "status": "waiting_for_human",
  "needs": {
    "kind": "approval",
    "prompt": "Approve: click button \"Redeem 500 points\" on Blue Mug – Rewards?",
    "fields": ["Quantity: \"1\"", "Ship to: \"Ada Lovelace, 12 Main St\"", "Gift wrap: unchecked"],
    "action": "Redeem 500 points",
    "screenshot": "/v1/browser/runs/brun_5b7d9f1a3c5e7a9b1d3f5a7c/screenshot",
    "since": "2026-10-11T09:33:40.000Z",
    "reply": "/v1/browser/runs/brun_5b7d9f1a3c5e7a9b1d3f5a7c/reply"
  }
}
```

`fields` lists the form's current values and `action` is the label of the button it wants to click. Reply:

- **`{"approve": true}`** — the agent clicks that button as its next step.
- **`{"approve": false, "reason": "Wrong address"}`** — the agent doesn't submit. It reads your reason (up to 500 characters) and tries another way, or finishes and reports why. `{"deny": true}` does the same.

```bash title="cURL"
curl https://lovelyrouter.com/v1/browser/runs/brun_5b7d9f1a3c5e7a9b1d3f5a7c/reply \
  -H "Authorization: Bearer $LOVELY_API_KEY" \
  -H "content-type: application/json" \
  -d '{"approve": true}'
```

## Hand over for CAPTCHAs

The agent can't solve CAPTCHAs or "verify you are human" checks. When a page shows one, or a step only a person can do, it hands the browser to you with `needs.kind` set to `takeover`:

```json
{
  "status": "waiting_for_human",
  "needs": {
    "kind": "takeover",
    "prompt": "Please solve the CAPTCHA and press Continue.",
    "live_view_url": "https://…",
    "live_view_expires_at": "2026-10-11T10:35:02.000Z",
    "screenshot": "/v1/browser/runs/brun_5b7d9f1a3c5e7a9b1d3f5a7c/screenshot",
    "since": "2026-10-11T09:35:02.000Z",
    "reply": "/v1/browser/runs/brun_5b7d9f1a3c5e7a9b1d3f5a7c/reply"
  }
}
```

1. Open `live_view_url` in a browser. It shows the agent's browser tab live, and the person can click and type in it. The link is valid for one hour.
2. The person does what `prompt` says.
3. Reply `{"done": true}`, optionally with a `note` (up to 500 characters) the agent reads, such as `"Solved the CAPTCHA, now on the search results"`. If the person couldn't do it, reply `{"failed": true, "note": "…"}`.

If the run notices on its own that the handover has ended, it continues without your reply, and a later reply returns `409`, which you can ignore. The agent also hands over when it is stuck: after three failed steps in a row, or after repeating the same action three times without progress.

A CAPTCHA or "verify you are human" page is handed over at most twice per run. If it is still there after that, or the person replied `{"failed": true}`, the run ends as `failed` with the error `blocked by the website`. So does a page that blocks the browser outright (access denied, 403), since a person can't get past it either, and any human check when the run was started with `"human": false`.

## Take over a run yourself

You can take the browser at any time, for example to show the agent the way or to finish a step by hand:

```bash title="cURL"
curl https://lovelyrouter.com/v1/browser/runs/brun_5b7d9f1a3c5e7a9b1d3f5a7c/takeover \
  -H "Authorization: Bearer $LOVELY_API_KEY" \
  -H "content-type: application/json" \
  -d '{"instructions": "I will pick the delivery slot myself."}'
```

The request returns `202` at once. The run pauses after the step in progress, and the next status shows `needs.kind` `takeover` with a `live_view_url`. Do what you need in Live View, then hand back with `{"done": true, "note": "Picked Saturday 10–12"}`. The agent continues from the page you left it on.

`instructions` (optional, up to 500 characters) becomes the pause's `prompt`. If the run is already waiting for a question or an approval, the takeover starts after you reply to it. A finished run returns `409` with `This run has finished`.

## Set the timeout

`human_timeout_minutes` sets how long each pause waits for a reply: 1 to 1,440 minutes, default 30 (5 for a run started by a cloud agent, which usually has nobody watching). When a cloud agent's session ends, its unfinished browser runs are stopped. The timer restarts at every pause. A run that times out ends as `timed_out` and is charged for its steps and the billable waiting time.

A longer timeout gives people more time to reply, but reserves more of your balance: the hold includes one wait to the timeout at $0.005 per minute. See [How the hold works](/docs/browser-agent/modes#how-the-hold-works).

## Run without a person

For unattended jobs, turn pauses off:

```json
{
  "goal": "Get the current mortgage rates table.",
  "start_url": "https://bank.example.com/rates",
  "human": false,
  "approve": "none"
}
```

- `"human": false` — the agent never asks questions or hands over. If it lacks information or is stuck, it carries on as best it can and the run may end as `failed`.
- `"approve": "none"` — it submits forms without waiting for approval.

With both set, the run reserves nothing for waiting time.

## Reply reference

`POST /v1/browser/runs/{id}/reply` takes exactly one of these bodies:

| Body | For `needs.kind` | Effect |
| --- | --- | --- |
| `{"answer": "…"}` | `question` | The agent reads your text (up to 2,000 characters). |
| `{"secret": "…"}` | `question` | The agent types the value on the current site without seeing it (up to 4,096 characters). |
| `{"approve": true}` | `approval` | The agent clicks the button as its next step. |
| `{"approve": false, "reason": "…"}` or `{"deny": true, "reason": "…"}` | `approval` | The agent doesn't submit and reads your reason. |
| `{"done": true, "note": "…"}` | `takeover` | The agent continues from the current page. |
| `{"failed": true, "note": "…"}` | `takeover` | The agent continues, knowing the person couldn't finish. |

It returns the run with `status` back to `running`. Errors:

| Status | Message | Cause |
| --- | --- | --- |
| `400` | `Reply with one of: …` | The body matched none of the shapes above. |
| `404` | `No such run` | Wrong id, or the run belongs to another account. |
| `409` | `This run is not waiting for a reply` | The run is running or has finished. |
| `409` | `This run is waiting for a reply of kind "question"` | The reply doesn't match `needs.kind` (the message names the kind expected). |
