Skip to content

The local HTTP API

Santiago runs a local daemon that exposes an HTTP API on http://localhost:7891. Every browser action your code or AI agent performs goes through this daemon, which drives the running Camoufox browser via Playwright. This page covers the base paths, the response envelope, status and error codes, and how the daemon protects those endpoints from other tabs on your machine.

The daemon listens on localhost only — it is never exposed to the network. If you are using the packaged agent skill, it already speaks this protocol for you; see Install the agent skill. The full per-action catalog lives in the API reference.

There are two base paths under http://localhost:7891/api:

PurposeBase path
Daemon management (license, profiles, update, etc.)http://localhost:7891/api
Browser automation actionshttp://localhost:7891/api/automation/:profileId/<action>

Automation endpoints always target one running profile, identified by its :profileId in the path. The <action> segment is the operation — for example navigate, click, snapshot, or screenshot.

http://localhost:7891/api/automation/:profileId/<action>

Every example below uses a shell variable for the profile id so you can copy commands without editing the path each time. Export it once with the id of a running profile:

Set the profile id once
export PROFILE="3f9a1c20-7b6e-4f0a-9c2d-1e8b5a6d4c01"

All later curl snippets reference $PROFILE in the URL.

Every endpoint returns JSON in one of two shapes. On success, ok is true and the payload lives under data:

Success
{ "ok": true, "data": { "url": "https://example.com/", "title": "Example Domain" } }

On failure, ok is false and error carries a machine-readable code plus a human-readable message:

Failure
{ "ok": false, "error": { "code": "PROFILE_NOT_RUNNING", "message": "Profile is not running" } }

Some actions that simply succeed without returning data respond with just { "ok": true } — for example click, hover, and scroll-to.

Navigate the running profile to a URL. navigate returns the resulting url and page title:

Navigate the profile to a URL
curl -s -X POST "http://localhost:7891/api/automation/$PROFILE/navigate" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com"}'
Response
{ "ok": true, "data": { "url": "https://example.com/", "title": "Example Domain" } }

Reading the page is just as simple. The snapshot action returns an accessibility tree where each interactive element carries a [ref=…] you can reuse in later actions:

Capture the page structure
curl -s -X POST "http://localhost:7891/api/automation/$PROFILE/snapshot" \
-H "Content-Type: application/json" \
-d '{}'
Response
{ "ok": true, "data": { "snapshot": "- heading \"Example\" [ref=e1]\n- textbox \"Email\" [ref=e2]\n..." } }

You then target elements by that ref, or by a CSS selector — most interaction endpoints accept either:

Click an element by ref
curl -s -X POST "http://localhost:7891/api/automation/$PROFILE/click" \
-H "Content-Type: application/json" \
-d '{"ref":"e1"}'
Response
{ "ok": true }

The daemon pairs each error envelope with a matching HTTP status, so you can rely on the status line alone for coarse handling.

StatusMeaningWhen you see it
200SuccessThe action ran; read data.
400Bad requestA required field is missing or invalid (e.g. an empty actions array, a tab index out of range, or trying to close the last tab).
401UnauthorizedYour account’s tokens are expired or invalid. The app needs to be signed in again.
404Not foundThe profile is not running (PROFILE_NOT_RUNNING) or there is no pending dialog (NO_DIALOG).
500Action failedThe browser operation threw — the element was not found, a navigation timed out, JavaScript errored, etc.

A 500 here is usually not a crash: it means the requested browser operation could not complete (for example, a locator timed out). The error.code tells you which action failed, and error.message carries the underlying Playwright message.

Error codes are grouped by the area they come from. Codes ending in _FAILED always carry the underlying browser error in message.

CodeStatusMeaning
PROFILE_NOT_RUNNING404The :profileId has no running browser. Launch it first.
BAD_REQUEST400A required field was missing or invalid (e.g. actions/fields empty, tab index out of range).
LAST_TAB400You tried to close the only open tab. Stop the profile instead.
NO_DIALOG404You called /dialog but no alert/confirm/prompt is pending.
CodeAction that produced it
NAVIGATE_FAILEDnavigate, back, forward, reload
SNAPSHOT_FAILEDsnapshot
SCREENSHOT_FAILEDscreenshot
CLICK_FAILEDclick
HOVER_FAILEDhover
DRAG_FAILEDdrag
SELECT_FAILEDselect-option
SELECT_COMBOBOX_FAILEDselect-combobox
TYPE_FAILEDtype
PRESS_SEQ_FAILEDpress-sequentially
KEY_PRESS_FAILEDpress-key
FILL_FORM_FAILEDfill-form
FILL_PAGE_FAILEDfill-page
SCROLL_FAILEDscroll-to
MOUSE_FAILEDmouse/move, mouse/click, mouse/down, mouse/up, mouse/wheel
TABS_FAILEDtabs (list)
TAB_NEW_FAILEDtabs/new
TAB_SELECT_FAILEDtabs/select
TAB_CLOSE_FAILEDtabs/close
EVALUATE_FAILEDevaluate
WAIT_FAILEDwait
BATCH_FAILEDbatch
DIALOG_FAILEDdialog

Interaction endpoints (click, hover, type, scroll-to, and similar) require either a ref (from a snapshot) or a CSS selector. If you send neither, the action fails with the message:

Either "ref" or "selector" must be provided

When an element does not exist on the page, the action fails fast and returns the matching _FAILED code with a 500:

Click a selector that isn't there
curl -s -X POST "http://localhost:7891/api/automation/$PROFILE/click" \
-H "Content-Type: application/json" \
-d '{"selector":"#does-not-exist"}'
Response
{ "ok": false, "error": { "code": "CLICK_FAILED", "message": "locator.click: Timeout 2000ms exceeded." } }

Because the daemon listens on localhost, any web page open in any browser on the same machine could, in principle, try to POST to it. A permissive CORS policy does not stop this: a “simple” cross-origin POST skips the CORS preflight entirely, and even when a preflight runs, a wildcard origin still passes it. CORS only governs whether a page can read the response — not whether the request reaches the daemon.

Santiago closes that gap with two layers of protection:

  • Origin check on writes. An onRequest hook rejects cross-origin POST, PATCH, and DELETE requests based on their Origin header. The bundled Santiago UI is served from the same origin as the daemon, so its requests pass through; a request originating from another site is rejected.
  • Secret-gated shutdown. The shutdown endpoint additionally requires an internal secret in a header. It is meant to be called only by the desktop app’s tray process, never by automation.

What this means in practice:

  • curl and local scripts work. Command-line clients and your own automation tooling do not send a browser Origin header, so the origin check does not block them. Every curl example on this page works as-is against a running daemon.
  • The same-origin UI works. The in-app interface shares the daemon’s origin and is allowed.
  • Random web pages are blocked. A page on some other site that tries to script your profiles is rejected by the Origin check.