earl

Browser

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

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:

earl call web.snapshot_page --url https://example.com

Walk-through

browser block

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 opens the URL and waits for the page to load. snapshot reads the accessibility tree and returns a JSON object with this shape:

{
  "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

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.

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.

For the full step reference, see Template Schema — Browser.

On this page