---
title: Browser
icon: Monitor
description: Automate Chrome from an Earl template.
---
The Browser protocol drives a headless Chrome instance using a sequence of steps defined in your template.
## A complete example
```hcl
version = 1
provider = "web"
categories = ["browser"]
command "snapshot_page" {
title = "Snapshot page"
summary = "Navigate to a URL and return the page's accessibility tree"
description = "Opens a URL in a headless browser and returns a text description of the page content."
annotations {
mode = "read"
}
param "url" {
type = "string"
required = true
description = "URL to open"
}
operation {
protocol = "browser"
browser {
headless = true
timeout_ms = 30000
on_failure_screenshot = true
steps = [
{ action = "navigate", url = "{{ args.url }}" },
{ action = "snapshot" },
]
}
}
result {
decode = "json"
output = "{{ result.text }}"
}
}
```
Run it:
```bash
earl call web.snapshot_page --url https://example.com
```
## Walk-through
### browser block
```hcl
browser {
headless = true
timeout_ms = 30000
on_failure_screenshot = true
steps = [...]
}
```
`headless = true` runs Chrome without a window. It is the default. `timeout_ms` is the global timeout for the entire command — if the steps haven't finished within that window, the command fails. `on_failure_screenshot = true` captures a screenshot when any step fails and attaches it to the error output, which helps debug layout or timing problems.
### steps
`steps` is an ordered list of inline objects. Each object needs an `action` field. Steps run in order and a failed step stops the command immediately. Two fields are available on every step regardless of action:
- `optional = true` — skip the step on failure and continue to the next one
- `timeout_ms` — overrides the command-level timeout for just this step
### navigate and snapshot
`navigate` opens the URL and waits for the page to load. `snapshot` reads the accessibility tree and returns a JSON object with this shape:
```json
{
"text": "...",
"raw": [...]
}
```
Use `result.text` in the `result` block to get the plain-text description. The `raw` field contains the full structured tree.
### ref handles
The snapshot text includes `ref` handles next to interactive elements — for example, `button "Submit" ref=42`. When writing multi-step commands, pass a `ref` value to `click` or `fill` steps instead of a CSS selector. Refs are more stable than selectors on pages that generate dynamic class names or restructure the DOM between renders. CSS selectors work fine for one-shot commands targeting stable elements. For dynamic pages or session-based commands where the DOM changes between steps, prefer refs — they come from the current snapshot and won't break when attributes shift.
### result
```hcl
result {
decode = "json"
output = "{{ result.text }}"
}
```
The shape of the result depends on the last step's action. `snapshot` returns `{"text": "...", "raw": [...]}`. Decode as `"json"` and address fields directly in the output template.
## Persistent session with login
One-shot commands — navigate, snapshot, done — work fine without a session. When you need to log in first and then run further commands in the same browser context, use `session_id`.
```hcl
command "login_and_snapshot" {
title = "Log in and snapshot"
summary = "Log in to an app and return the dashboard accessibility tree"
description = "Logs in with the given credentials and returns the dashboard page. Uses a persistent session so subsequent commands stay logged in."
annotations {
mode = "write"
secrets = ["myapp.password"]
}
param "username" {
type = "string"
required = true
description = "Login username"
}
param "session_id" {
type = "string"
required = false
default = "main"
description = "Session identifier — reuse to stay logged in across commands"
}
operation {
protocol = "browser"
browser {
session_id = "{{ args.session_id }}"
headless = true
timeout_ms = 60000
on_failure_screenshot = true
steps = [
{ action = "navigate", url = "https://app.example.com/login" },
{ action = "fill", selector = "#username", text = "{{ args.username }}" },
{ action = "fill", selector = "#password", text = "{{ secrets['myapp.password'] }}" },
{ action = "click", selector = "button[type=submit]" },
{ action = "wait_for", text = "Dashboard", timeout_ms = 10000 },
{ action = "snapshot" },
]
}
}
result {
decode = "json"
output = "{{ result.text }}"
}
}
```
The login form uses CSS selectors — `#username` and `#password` are stable IDs unlikely to change. For dynamic content after login, use `snapshot` first and target elements by ref.
`session_id` keeps the browser instance alive between commands. A second command with the same `session_id` reconnects to the same instance, preserving cookies, localStorage, and page state. Omit it for one-shot commands that don't need to carry state forward.
Secrets are available in step fields via `{{ secrets['key.name'] }}`. The actual secret value is resolved at runtime from the OS keychain and never appears in the template or in command output.
---
For naming conventions and other patterns that apply across all protocols, see [Best Practices](/docs/best-practices).
For the full step reference, see [Template Schema — Browser](/docs/template-schema#browser).