Skip to content

Best practices & stealth

These are the rules that keep Santiago automation both fast and undetectable. Follow them and a typical form flow runs in a few hundred milliseconds per page with humanized, trusted input; ignore them and you either leak isTrusted: false events to antibot systems or waste tens of seconds on blind waits.

All examples target the local daemon. Set your profile ID once, then call only /api/automation/$PROFILE/* actions:

Set the profile
PROFILE=<profile-id>

The response envelope is always { "ok": true, "data": {...} } or { "ok": false, "error": { "code", "message" } }.

evaluate runs page JavaScript through the debugging protocol. The protocol itself is invisible to the page, but what your code does inside evaluate is fully visible to site scripts. Use it to read, never to interact.

Safe (invisible to the page)Forbidden (detectable, bypasses humanize)
document.titleelement.click()
querySelectorAll, attribute valueselement.focus()
getBoundingClientRect() (element coords)element.dispatchEvent(...)
computed stylessetting element.value = ...
reading document.activeElement, aria-expandedprogrammatic form.submit(), scrolling

A synthetic element.click() produces a MouseEvent with isTrusted: false, clientX/Y: 0, and no preceding mousemove / pointerdown / mouseover. Modern antibot systems flag isTrusted: false directly. When the profile has humanize enabled, Camoufox adds bezier-curve cursor movement to native Playwright actions only — anything you do via evaluate bypasses it entirely.

Read coords with evaluate, then click them
# OK -- evaluate stays read-only
curl -s localhost:7891/api/automation/$PROFILE/evaluate -X POST \
-H 'Content-Type: application/json' \
-d '{"code":"JSON.stringify(document.querySelector(\"#btn\").getBoundingClientRect())"}'
# Trusted, humanized click on the coords you just read
curl -s localhost:7891/api/automation/$PROFILE/mouse/click -X POST \
-H 'Content-Type: application/json' -d '{"x":512,"y":340}'
FORBIDDEN -- detectable, bypasses humanize
# curl ... /evaluate -d '{"code":"document.querySelector(\"#btn\").click()"}'
# curl ... /evaluate -d '{"code":"document.querySelector(\"input\").value = \"x\""}'
# curl ... /evaluate -d '{"code":"form.submit()"}'

fill-page is the default choice for any form. It bypasses Playwright locators entirely (evaluatemouse.clickkeyboard.type), so it works on complex, animated and transitioning pages where ref-based actions stall on stability checks. It handles text inputs and combobox dropdowns, and includes the submit in the same call.

Fill all fields and submit in one call
curl -s localhost:7891/api/automation/$PROFILE/fill-page -X POST \
-H 'Content-Type: application/json' -d '{
"fields": [
{"selector": "#firstName", "value": "Daniel"},
{"selector": "#lastName", "value": "Moreno"},
{"selector": "[role=combobox]", "value": "March", "type": "combobox", "nth": 0},
{"selector": "[role=combobox]", "value": "Male", "type": "combobox", "nth": 1}
],
"submit": {"text": "Next"},
"waitAfterSubmit": 2500
}'
# Response: { results: [{selector, type, ok, selectedValue?}], submit: {ok, url} }

Field types:

  • "text" (default): click center → Ctrl+A → Backspace → keyboard.type.
  • "combobox": click to open → find option by text → click option → verify and auto-retry.
  • nth (0-based): when selector matches multiple visible elements, pick the Nth one — [role=combobox] with nth: 0 is the first, nth: 1 the second.

Use batch for non-form actions (clicking links, hover, navigate, wait) or when you need ref-based targeting from a snapshot. fill-form is legacy — it tries a locator first (2s) and falls back to coords; prefer fill-page.

See Filling forms for a complete worked flow.

How the dropdown appears in the snapshot decides the endpoint.

Snapshot showsUseBehavior
<select> (native element)select-option with values[]sets the native value directly
combobox role (ARIA)select-combobox with valueclick → wait for listbox → click option, one call
Native select
curl -s localhost:7891/api/automation/$PROFILE/select-option -X POST \
-H 'Content-Type: application/json' -d '{"ref":"e2","values":["California"]}'
ARIA combobox
curl -s localhost:7891/api/automation/$PROFILE/select-combobox -X POST \
-H 'Content-Type: application/json' -d '{"ref":"e5","value":"March"}'

Some sites render the listbox in a detached overlay outside the combobox’s aria-controls target. select-combobox then times out with “locator #cN is hidden”. Do not retry select-combobox — instead open the dropdown with a real click, read the option’s center coordinates via evaluate (reading only), then mouse/click those coordinates (trusted, humanized):

Detached-overlay dropdown fallback
# 1. Open the dropdown with a real click
curl -s localhost:7891/api/automation/$PROFILE/click -X POST \
-H 'Content-Type: application/json' -d '{"ref":"e102"}'
# 2. Read the target option's center -- evaluate stays read-only
curl -s localhost:7891/api/automation/$PROFILE/evaluate -X POST \
-H 'Content-Type: application/json' \
-d '{"code":"const o = Array.from(document.querySelectorAll(\"li[role=option]\")).filter(e => e.offsetParent !== null).find(e => e.textContent.trim() === \"June\"); const r = o.getBoundingClientRect(); JSON.stringify({x: r.x + r.width/2, y: r.y + r.height/2})"}'
# 3. Click the coords with the mouse (trusted, humanized)
curl -s localhost:7891/api/automation/$PROFILE/mouse/click -X POST \
-H 'Content-Type: application/json' -d '{"x":512,"y":340}'

One snapshot, one batch — re-snapshot only on page change

Section titled “One snapshot, one batch — re-snapshot only on page change”

Take one snapshot, identify all the elements you need, then send one batch call with every action. Never re-snapshot between actions on the same page.

The shape of a good flow
snapshot (~110ms) -> batch of N actions (N x ~200ms) -> done

Only take a new snapshot when the page fundamentally changes:

  • navigation to a new URL,
  • form submission that loads a new page,
  • a modal or overlay that replaces page content.

Do not re-snapshot after filling fields, opening dropdowns, typing, or hovering. Refs (e1, e2, …) stay valid until the page changes; they only go stale after navigation, submission to a new URL, reload, or DOM-replacing dynamic content.

A batch stops on the first error — if an action fails (wrong ref, element not found), the remaining actions are skipped. Inspect the results array ({ index, action, ok, data?, error? }) to find the failed action, fix it, and re-run from that point.

Playwright auto-waits for elements before acting, and the batch endpoint already adds random 80–250ms delays between actions for human-like pacing. Wrapping calls in sleep 1 / sleep 2 && curl … is pure waste that compounds into tens of seconds across a flow.

If a flow feels more than ~2× slower than expected, the cause is almost always an added sleep or a mid-page re-snapshot — remove both.

When you genuinely need to wait for a specific signal, use wait with a condition, never a blind timeout:

Wait for a real signal, not a clock
curl -s localhost:7891/api/automation/$PROFILE/wait -X POST \
-H 'Content-Type: application/json' -d '{"text":"Success"}'
curl -s localhost:7891/api/automation/$PROFILE/wait -X POST \
-H 'Content-Type: application/json' -d '{"selector":".loaded","state":"visible"}'
curl -s localhost:7891/api/automation/$PROFILE/wait -X POST \
-H 'Content-Type: application/json' -d '{"selector":".spinner","state":"hidden"}'

If an action failed, retrying with the same parameters fails the same way. Every retry must change something — a different endpoint, a different selector, keyboard instead of mouse. Re-reading the current error before retrying is mandatory.

The clearest example: select-combobox times out on a hidden #cN listbox. Do not fire it again — switch to the open-click → read-coords → mouse/click strategy above. The action timeout is 2 seconds (not Playwright’s default 30s), so failed attempts are cheap; spend that cheapness on a new approach, not the same one five times.

Debug checklist before you change strategy

Section titled “Debug checklist before you change strategy”

One read-only evaluate returning these four values tells you which strategy will work, before you burn retries:

  1. document.activeElement — did focus land where you expected?
  2. aria-expanded on the combobox — did it actually open?
  3. aria-activedescendant — which option is highlighted?
  4. getBoundingClientRect().y vs window.innerHeight — is the target even in the viewport?

Keyboard type-ahead for stubborn dropdowns

Section titled “Keyboard type-ahead for stubborn dropdowns”

When fill-page / select-combobox don’t fit (custom combobox markup, scrollable listboxes whose options live outside the viewport, mouse clicks that fail to focus), switch to the keyboard first, not to coord-hacking. Mouse coord-clicks on listbox options should be a last resort — they require the option to be inside the viewport and inside the listbox’s own clip rect, and scrolling a listbox is unreliable.

  • Type-ahead on comboboxes — open with Enter or a click, then press-key the target value character-by-character (1, 9, 9, 3 for year 1993). Most ARIA comboboxes jump straight to the match, skipping any scrollTop gymnastics. Commit with Enter.
  • Tab to traverse focus — when a click refuses to focus an element, or the submit button is below the viewport and wheel scroll is intercepted, Tab from the last known focused element until document.activeElement.textContent matches, then activate with Enter.
  • ArrowDown × N + Enter — works for short option lists (≤5 items, e.g. Gender: Female / Male / Custom).
Type-ahead a birth year into a combobox
# Open the combobox with a real click (trusted)
curl -s localhost:7891/api/automation/$PROFILE/click -X POST \
-H 'Content-Type: application/json' -d '{"ref":"e12"}'
# Type-ahead the value, one key at a time
for k in 1 9 9 3; do
curl -s localhost:7891/api/automation/$PROFILE/press-key -X POST \
-H 'Content-Type: application/json' -d "{\"key\":\"$k\"}"
done
# Commit the selection
curl -s localhost:7891/api/automation/$PROFILE/press-key -X POST \
-H 'Content-Type: application/json' -d '{"key":"Enter"}'
SymptomDo this
”Ref eN not found”Page changed. Re-snapshot.
select-option fails: “not a select element”It’s an ARIA combobox — use select-combobox.
select-combobox times out on hidden #cNDetached overlay. Don’t retry — open with click, read coords via evaluate, mouse/click.
locator.scrollIntoViewIfNeeded: TimeoutElement is animating. Use fill-page (coord-based); switch the whole page.
Snapshot too largeScope it: {"selector":"#main","depth":5}.
Element not visiblemouse/wheel {"deltaY":500}, then re-snapshot.
Flow feels >2× slowYou added sleep or re-snapshotted mid-page. Remove both.
Need to click something found via evaluateRead its getBoundingClientRect(), then mouse/click {x, y}. Never .click() in evaluate.