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" }
}
}
outputscontains only steps that have anoutput_id. Screenshots are base64-encoded PNGs with a data URI prefix. Everything else is the step’sdatadict as-is.step_resultsis the full execution log of every step (action, duration, success/error).- Logs go to stderr, so
> results.jsongives you clean JSON. - Exit code is 0 if all steps succeed, 1 if any fail.
Key features
output_idon any step collects its result into theoutputsdict. This is how you get data out.${env.VAR_NAME}in any string value is replaced with the environment variable. Pass-e VAR=valueto Docker.save_screenshotcaptures the browser viewport (or full desktop withtype: desktop). Supportswidth,height,whLargestfor resize. Can also write to disk withpath: /output/file.png(in addition tooutput_id).on_error: continuekeeps 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
gotoif 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.