Script mode (run & exit)

Run a YAML script at container startup. The container executes the steps, prints the results as JSON on stdout, and exits. No HTTP server, no long-running process. Good for CI, cron jobs, one-shot scraping, or anything where you want to automate a sequence and get the output.

Usage

# Pipe a script in, get JSON results out
cat my-script.yaml | docker run --rm -i \
  psyb0t/stealthy-auto-browse --script > results.json

# Parameterize with environment variables
cat my-script.yaml | docker run --rm -i \
  -e TARGET_URL=https://example.com \
  psyb0t/stealthy-auto-browse --script

Script format

name: Scrape Example
on_error: stop  # "stop" (default) or "continue"
steps:
  - action: goto
    url: ${env.TARGET_URL}
    wait_until: networkidle

  - action: sleep
    duration: 2

  - action: save_screenshot
    output_id: page_screenshot

  - action: get_text
    output_id: page_text

  - action: eval
    expression: "document.title"
    output_id: title

Output JSON

The JSON printed to stdout looks like this:

{
  "name": "Scrape Example",
  "success": true,
  "steps_executed": 5,
  "steps_total": 5,
  "duration": 3.42,
  "step_results": [ ... ],
  "outputs": {
    "page_screenshot": "data:image/png;base64,iVBOR...",
    "page_text": { "text": "...", "length": 1234 },
    "title": { "result": "Example Domain" }
  }
}
  • outputs contains only steps that have an output_id. Screenshots are base64-encoded PNGs with a data URI prefix. Everything else is the step’s data dict as-is.
  • step_results is the full execution log of every step (action, duration, success/error).
  • Logs go to stderr, so > results.json gives you clean JSON.
  • Exit code is 0 if all steps succeed, 1 if any fail.

Key features

  • output_id on any step collects its result into the outputs dict. This is how you get data out.
  • ${env.VAR_NAME} in any string value is replaced with the environment variable. Pass -e VAR=value to Docker.
  • save_screenshot captures the browser viewport (or full desktop with type: desktop). Supports width, height, whLargest for resize. Can also write to disk with path: /output/file.png (in addition to output_id).
  • on_error: continue keeps going past failures. on_error: stop (default) halts on the first error.
  • All HTTP API actions work as script steps: iframe and role targeting, element references, DOM observation, trusted pointer gestures, handoff, windows, events, network rules, permissions, rich clipboard data, PNG capture, and the existing browser actions.
  • Page loaders still fire on goto if configured.

Human input in scripts

Use human_pause only when a visible task naturally includes reading, reviewing, or composing. It keeps the pointer mostly still and can make sparse bounded drifts. Use sleep for ordinary waits, and do not add human_pause between every step.

For substantial text, write the real X11 clipboard, focus the field, and paste with Ctrl+V. Use system_type for short edits, paste-hostile fields, or tests that need per-keystroke behavior.

steps:
  - action: goto
    url: ${env.TARGET_URL}
  - action: human_pause
    duration: 4
    drift: true
    drift_interval: 12
  - action: set_clipboard
    text: ${env.MESSAGE}
  - action: click
    selector: "textarea"
  - action: send_key
    key: ctrl+v

Control flow

Alongside ordinary action steps, scripts can use one control node per step: if, repeat, or while. Control nodes are intentionally explicit and bounded; they cannot carry sibling action fields.

steps:
  - action: goto
    url: ${env.TARGET_URL}

  - if:
      condition:
        type: element
        selector: ".cookie-banner"
        state: visible
        timeout: 2
      then:
        - action: click
          selector: ".cookie-banner button.accept"
      else:
        - action: eval
          expression: "true"
          output_id: no_cookie_banner

  - repeat:
      count: 3
      steps:
        - action: scroll
          amount: -4

  - while:
      condition:
        type: text
        text: "Load more"
      max_iterations: 5
      steps:
        - action: click
          selector: "button.load-more"

if runs then when its condition matches and else when it does not. An omitted else is a successful no-op. repeat.count must be an integer from 1 to 100. while.max_iterations is mandatory and has the same range; if the condition still matches after that many body executions, the control step fails instead of silently truncating the workflow. All loop bodies together are limited to 1,000 executed steps, and control/condition nesting is limited to 8 levels.

The script-level on_error policy applies inside every nested block. Each control node appears in step_results with its branch or per-iteration nested results, while top-level steps_executed and steps_total retain their existing top-level-step meaning.

Conditions

Conditions are mappings with a type. timeout is optional (0 by default), polls for up to 60 seconds, and belongs only on the outer condition.

Type Required fields Matches when
element selector; optional state CSS selector reaches visible (default), hidden, attached, or detached.
text text The page body’s visible text contains the string.
url matches The current URL matches the glob pattern, e.g. *dashboard*.
javascript expression The expression evaluates to the boolean true in the page’s own JavaScript world, like eval. Non-boolean results fail the control step.
output output_id and exactly one of equals or exists A prior output_id exists or its optional list path equals the supplied JSON value.
all / any conditions Every / any nested condition matches.
not condition Its nested condition does not match.

Environment placeholders are substituted recursively, including strings in control blocks: text: "${env.EXPECTED_TEXT}". Conditions use the existing page-evaluation capability; only use scripts on targets you are authorized to automate.

Example: detect a challenge and pause for human review

detect_challenge is a read-only page-inspection action. It can tell a workflow that a documented verification widget or a conservative generic challenge cue is present, but it never clicks, solves, or enters a challenge frame. Set scroll_into_view: true to bring the first visible detected frame or widget into the viewport for a human takeover; that still never clicks or focuses it. Use an output condition to hand the decision to your orchestration layer.

When CAPTCHA_SOLVER_PROVIDER=anticaptcha is configured, solve_captcha can submit an Anti-Captcha task from a script. Pass the provider task object without clientKey; the server adds its configured key. Extension-backed providers solve through their browser extensions and need no script action.

steps:
  - action: detect_challenge
    scroll_into_view: true
    output_id: challenge
  - if:
      condition:
        type: output
        output_id: challenge
        path: [detected]
        equals: true
      then:
        - action: eval
          expression: "'notify-human'"
          output_id: disposition

challenge.status is absent, present, or unknown. When scroll_into_view is enabled, challenge.scrolled_into_view reports whether a detected target is in the viewport after the action. Treat unknown as an operational decision point rather than proof that a challenge is absent. See api.md#challenge-detection for the bounded result schema and supported signatures.

Example: screenshot a URL

name: Quick Screenshot
steps:
  - action: goto
    url: ${env.URL}
    wait_until: networkidle
  - action: save_screenshot
    output_id: screenshot
    whLargest: 1024
cat screenshot.yaml | docker run --rm -i -e URL=https://example.com \
  psyb0t/stealthy-auto-browse --script | \
  python3 -c "import sys,json,base64; d=json.load(sys.stdin); open('out.png','wb').write(base64.b64decode(d['outputs']['screenshot'].split(',')[1]))"

See scripts/example_script.yaml for a full example.

Example: record a flow

Mount /recordings and pair start_recording with stop_recording. Multiple pairs in a single script land multiple MP4 files; collect them from the host after the container exits.

name: Record Search
steps:
  - action: start_recording
    mode: viewport
    fps: 20
  - action: goto
    url: ${env.URL}
    wait_until: networkidle
  - action: sleep
    duration: 2
  - action: stop_recording
    slug: search-flow
mkdir -p ./recordings
cat record.yaml | docker run --rm -i \
  -v ./recordings:/recordings \
  -e URL=https://example.com \
  psyb0t/stealthy-auto-browse --script
# → ./recordings/search-flow.mp4

Full action reference: api.md#screen-recording.