> ## Documentation Index
> Fetch the complete documentation index at: https://docs.heylua.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Use the browser

> Pick the right Browser method, lock a browser to its sites, ask before it submits anything, use saved logins and files safely, and keep sessions short

After this guide, your agent drives a real browser for the work that needs one, and does it the cheap, boring way: the narrowest method that does the job, a browser locked to the sites it needs, a person's approval before anything is submitted, and someone's saved login only where it is genuinely required. The [`Browser`](https://docs.heylua.ai/reference/sdk/browser) reference has every option and error code; this page is about which ones to reach for.

*Verified against lua-cli 3.58.0.*

**Before you begin**

* A project created with `lua init` and signed in with `lua auth configure` ([Install and sign in](https://docs.heylua.ai/get-started/install.md)).
* `browser: true` on the agent, or at least one [`LuaBrowser`](https://docs.heylua.ai/reference/sdk/luabrowser) in `browsers`, then `lua push agent`.

## Pick the narrowest way in

A browser session costs money and time. Start at the top of this list and only move down when the one above cannot do it.

| Reach for | When | Costs |
| - | - | - |
| `Browser.read(url)` | One page, and you know its URL. Pages that need JavaScript to render, prices, stock, docs. | One short session, no model calls. |
| The step-by-step commands | You know the clicks: open, fill, click, read. Forms, portals, anything you can describe exactly. | One session for as long as you hold it, no model calls. |
| `Browser.execute({ task })` | You can describe the outcome but not the clicks, or the page changes often. | A session plus a model call per step. |
| `lua browser read` / `lua browser run` | Trying a page or a task before you write code for it. | Same as above, on the `dev` channel. |

```ts theme={null}
const page = await Browser.read('https://supplier.example.com/stock/SKU-1', { waitFor: '.stock' });
if (page.blocked) return { blocked: page.blocker, guidance: page.blockerGuidance };
```

`Browser.read` opens its own session and closes it again, so it is the cheapest call in the API. Use the step-by-step commands when one page is not enough, and give them a `sessionKey` so they share one session:

```ts theme={null}
const s = { sessionKey: 'restock' };
await Browser.navigate({ ...s, url: 'https://supplier.example.com/orders' });
await Browser.fill({ ...s, selector: '#sku', text: 'SKU-1' });
await Browser.click({ ...s, selector: 'button[type=submit]' });
const total = await Browser.get({ ...s, what: 'text', selector: '.order-total' });
await Browser.close(s);
if (total.error) throw new Error(`${total.code}: ${total.error}`);
```

Keep `Browser.execute` for the parts you cannot pin down, give it a `schema` so the answer has a shape, and cap it:

```ts theme={null}
const run = await Browser.execute({
  task: 'Find the lead time for SKU-1 on the product page',
  startUrl: 'https://supplier.example.com/p/SKU-1',
  schema: { type: 'object', properties: { leadTimeDays: { type: 'number' } }, required: ['leadTimeDays'] },
  maxSteps: 8,
  maxCostUsd: 0.2,
});
if (run.status !== 'completed') return { failed: run.stopReason ?? run.code };
```

## Name the sites, always

`allowedDomains` is the one setting worth having on every browser. It is checked before every command that opens a URL, it covers redirects and everything a page loads, and it is enforced on the server, so a prompt-injected page cannot talk the agent into leaving.

```ts src/browsers/supplier.ts theme={null}
import { defineBrowser } from 'lua-cli';

export const supplierPortal = defineBrowser({
  name: 'supplier-portal',
  description: 'Checks stock and places restock orders on the supplier site',
  allowedDomains: ['supplier.example.com', '*.supplier-cdn.example.com'],
  confirmActions: ['submit'],
});
```

Include the hosts a page loads its content from, or the page renders empty. Setting the list also keeps every page on the Lua cloud browser, because the list cannot be enforced on a remote one.

## Ask before it submits, pays or sends

Put every action that changes something at the other end in `confirmActions`. `submit` covers clicking Buy, Pay, Place order, Sign in or Send, and pressing Enter.

```ts theme={null}
confirmActions: ['submit']
```

When a run reaches one, it stops with `stopReason: 'confirmation_required'` and a `pendingAction`. Tell the person what is waiting, in their words, and approve it from their **next** message:

```ts theme={null}
if (run.stopReason === 'confirmation_required' && run.pendingAction?.confirmationId) {
  return { ask: `Place the order for ${qty} units?`, confirmationId: run.pendingAction.confirmationId };
}
```

```ts theme={null}
await Browser.confirm({ sessionKey: 'restock', id: confirmationId });
```

A held action cannot be approved in the same turn that held it, and it is dropped after 60 seconds. A job or a webhook has nobody to ask, so it can hold an action but never approve one: do not put a confirmed step inside a job.

## Logins: ask for the least access

A [saved login](https://docs.heylua.ai/reference/sdk/browser.md#saved-logins) is the person's own account, not the agent's. Ask for one only when the page genuinely needs it, and ask for `read` unless the work really writes:

```ts theme={null}
const receipt = await Secrets.request({
  kind: 'browser-login',
  name: 'supplier',
  origins: ['supplier.example.com'],
  loginUrl: 'https://supplier.example.com/signin',
  access: 'read',
  reason: 'Check stock levels on the supplier portal',
});
return { name: receipt.name, state: receipt.state, sent: receipt.delivery?.delivered === true };
```

* **`read`** can open, navigate, scroll and read. Most work is read.
* **`act`** can click, fill, type, select and press, and the person approves each one.
* **`act-auto`** does those without asking. Ask for it only for work the person has already said yes to as a whole, such as a nightly task they set up.

Rules worth following:

* **Never put a password in a tool, a prompt or an environment variable.** There is no API that takes one. The person types it on a Lua page, and the browser fills it in.
* **Never ask for a password or a verification code in the chat.** When a site asks for a code, Lua sends the person a private link; say that, and stop.
* **One login per site per person**, named for the site: `'supplier'`, `'linkedin'`. Asking again with the same name replaces that person's login; a different name on the same site just leaves two logins to go stale. A name already saved against different `origins` is refused with `browser_login_origin_changed`.
* **Return names and states from a tool, never a receipt.** The receipt carries no link, but keep the habit.

## Do not loop on a failed sign-in

A saved login is tried sparingly on purpose: one attempt per 30 minutes by default, and a sign-in that did not work starts a 30-minute cooldown. Sites lock accounts out, and a retry loop spends the person's attempts for them.

```ts theme={null}
const signIn = await Browser.login({ credential: 'supplier', sessionKey: 'restock' });
if (signIn.status !== 'signed_in') {
  return { tellUser: signIn.reason, failure: signIn.failure };
}
```

`reason` is already written for the person, so pass it along rather than inventing one. On `too_many_attempts`, say when they can try again and stop. On `wrong_password`, ask them to add the login again. On `needs_verification`, `captcha` or `needs_approval`, tell them a private link is on its way and that the run waits for them. Never start a second sign-in for the same login.

A signed-in session is saved and reused next time, so a second run usually comes back with `restored: true` and no sign-in at all.

## Let a job use a login only with a grant

A job has nobody to ask, so a login is off limits to it until the person says otherwise on a live turn. The grant names the job by its id, which [`Jobs.getAll`](https://docs.heylua.ai/reference/sdk/jobs) gives you:

```ts theme={null}
const [job] = (await Jobs.getAll()).filter((j) => j.name.startsWith('nightly-restock'));
const grant = await Browser.allowJobLogin({ credential: 'supplier', jobId: job.id, access: 'read' });
if (grant.error) return { failed: grant.code };
```

Then the job signs in as usual. Pair it with a tool that takes the grant away again:

```ts theme={null}
await Browser.revokeJobLogin({ credential: 'supplier', jobId: job.id });
```

A job that signs in without a grant is refused with `browser_login_grant_required`. The person can also remove the login or the grant themselves, which deletes the saved session, so handle a sign-in that stops working rather than assuming it will always be there.

## Files: smallest cap, named types, mind the scan

[Files](https://docs.heylua.ai/reference/sdk/browser.md#files) are off until a browser sets `maxDownloadMb`. Set the smallest number that fits the work, and name the types:

```ts src/browsers/invoices.ts theme={null}
import { defineBrowser } from 'lua-cli';

export const invoices = defineBrowser({
  name: 'invoices',
  allowedDomains: ['billing.example.com'],
  maxDownloadMb: 10,
  allowedMimeTypes: ['application/pdf'],
});
```

A download arrives with `scanStatus: 'scanning'` and cannot be opened or uploaded until the malware scan clears it:

```ts theme={null}
const saved = await Browser.download({ browser: 'invoices', ref: '@e7', name: 'march-invoice.pdf' });
if (saved.error) return { failed: saved.code };
const file = saved.data as { fileId: string; name: string; url: string; scanStatus: string };
// A file this run just saved is always 'scanning'. Hand it over and let it open once the scan clears.
return { file: file.name, url: file.url, ready: file.scanStatus === 'clean' };
```

* There is no download by URL. Take a `snapshot`, find the link or button, and click it with `download({ ref })`.
* Files have their own confirm categories. Add `'download'`, `'upload'` or `'pdf'` to `confirmActions` when the person should see each file before it moves.
* Uploads take only the same person's own files, and only after their scan passed. If the file is from an older message, ask the person to send it again and upload it in that turn rather than digging for an old one.
* A file over the cap, or a run that has used up its per-run total, is refused with `file_cap`. Download one file per run where you can.
* Files are not available on the Browser-Use browser (`remote_session`), and in a signed-in session `download` and `upload` need an `act-auto` login.

## Page scripts only when there is no command

`Browser.eval` is the last resort, not a shortcut. Reach for `get`, `snapshot`, `find` or `extract` first; use a script only for something no command exposes, such as a value the page computes.

```ts theme={null}
const rows = await Browser.eval({ sessionKey: 'reports', js: 'document.querySelectorAll("table tbody tr").length' });
```

It needs `allowScripts: true` **and** an `allowedDomains` list naming a real domain, so a browser that can run scripts is always a browser locked to its sites. Keep that browser separate from the one that opens arbitrary pages. Scripts are developer-only: the chat model never runs them, `Browser.execute` never runs them, and they never run in a signed-in session. Add `'eval'` to `confirmActions` when even your own scripts should wait for a person.

## Keep sessions short

Sessions are the cost. A session stays open until you close it, its idle timeout passes (120 seconds by default), or its length limit does (10 minutes by default).

* **Use `Browser.read` for one page.** It closes its own session.
* **Close what you open.** `await Browser.close({ sessionKey })` at the end of a tool, and in your error path too.
* **Do not raise the limits to cover a slow page.** Raise `timeoutMs` on the call instead.
* **Watch concurrency.** An agent may hold 3 cloud sessions at once for one person by default, and at most 3 `Browser.execute` runs; one too many fails with `BROWSER_SESSION_LIMIT` or `BROWSER_EXECUTE_CONCURRENCY`. Give each piece of work its own `sessionKey`, and do not open one per item in a loop.
* **Cap every `execute`.** `maxSteps` and `maxCostUsd` are the only things between a confused model and a long bill.

## Try it from the terminal first

Both commands run on the platform against the agent you pushed, so they show you exactly what your code will get:

```bash theme={null}
lua browser read https://supplier.example.com/p/SKU-1 --wait-for networkidle
lua browser read https://supplier.example.com/p/SKU-1 --selector .stock --json | jq .data.content
lua browser run "Find the lead time for SKU-1" --start-url https://supplier.example.com/p/SKU-1 --max-cost 0.25
lua browser run "Read my newest notifications" --login supplier
lua logs --type browser
```

`lua logs --type browser` is where the browser tells you the truth: which engine ran, what it was refused, which wall it hit, which files it moved and which sign-ins it tried. Read it after every failure before changing code.

## When it goes wrong

| What you see | What to do |
| - | - |
| `BROWSER_DISABLED` | The agent has no browser. Add `browser: true` or a `LuaBrowser`, then `lua push agent`. |
| `BROWSER_DOMAIN_NOT_ALLOWED` | Add the host to `allowedDomains`, including the hosts the page loads from. |
| `BROWSER_PROFILE_NOT_FOUND` | The `browser` name is not attached. Add it to `browsers`, `lua push browser` and `lua push agent`. |
| `blocked: true` with `blocker: 'login_required'` | The page needs a signed-in session. Ask the person for a [saved login](https://docs.heylua.ai/reference/sdk/browser.md#saved-logins), or use a public URL. |
| `blocked: true` with `blocker: 'bot_wall'` | The site refused an automated browser. Try the page on the person's desktop browser, or find a public source. |
| `session_lost` | The session ended. Open the page again; do not retry the same command. |
| `BROWSER_SESSION_LIMIT` | Too many sessions open for this person. Close one, or reuse a `sessionKey`. |
| `files_disabled` / `scripts_disabled` | The browser has no `maxDownloadMb`, or no `allowScripts` with a real domain. |
| `file_not_clean` | The file is still being scanned, or its scan failed. Hand the person its name and link, and let it open when the scan clears. |
| `browser_logins_disabled` | Saved logins are turned off on this platform. Tell the person the sign-in is not available. |
| `too_many_attempts` | A cooldown is running. Say when they can try again, and stop. |

## Next

* [`Browser`](https://docs.heylua.ai/reference/sdk/browser) — every method, saved logins, files, page scripts, limits and error codes
* [`LuaBrowser`](https://docs.heylua.ai/reference/sdk/luabrowser) — named browsers and every setting one takes
* [`lua browser`](https://docs.heylua.ai/reference/cli/browser) — the terminal commands in full
* [Logs and debugging](https://docs.heylua.ai/ship/logs-and-debugging.md) — reading agent logs


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.