---
name: sophtron-browser-stack
version: 0.6.0
description: Drive a real browser through the Sophtron Browser Stack API. Observe a page as an accessibility tree with element refs, an agent-only annotated screenshot and a clean user-facing screenshot, then click, type, scroll or navigate by ref. Type passwords and other secrets with per-session RSA encryption, or hand a one-time secure form to your sign-in vault.
homepage: https://unblock.sophtron.com
metadata: {"api_base": "https://unblock.sophtron.com/api/v1"}
---

# Sophtron Browser Stack

Sophtron runs a real browser for you. You drive it with a small REST API: **observe** the page, decide what to do, send one **action**, and repeat.

**Base URL:** `https://unblock.sophtron.com/api/v1`

## 1. Register (once)

```bash
curl -s -X POST https://unblock.sophtron.com/api/v1/agents/register \
  -H "Content-Type: application/json" \
  -d '{"name": "my-agent", "description": "What I do"}'
```

```json
{"success": true, "data": {"agent_id": "…", "api_key": "sk_sph_…", "expires_at": "…"}}
```

**Save `api_key` now.** It is shown once. Send it as `Authorization: Bearer <api_key>` on every other call, and only to `https://unblock.sophtron.com`. Keys expire after 30 days; then calls answer `401 unauthorized` and your sessions are deleted. Register again for a new key.

## 2. Quick start

```bash
KEY="sk_sph_…"

# Init: url is required and declares the session's target site and navigation scope (§6.4).
curl -s -X POST https://unblock.sophtron.com/api/v1/sessions -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" -d '{"url": "https://www.example.com/"}'
# {"success": true, "data": {"session_id": "…", "state": "creating", "expires_at": "…",
#   "sensitive_text_key": {"algorithm": "RSA-OAEP-256", "key_id": "…",
#     "public_key": "-----BEGIN PUBLIC KEY-----\n…\n-----END PUBLIC KEY-----\n",
#     "max_plaintext_bytes": 446}}}

# Observe until state is "ready". Wait a few seconds between calls.
curl -s -X POST https://unblock.sophtron.com/api/v1/sessions/$SESSION_ID/observe -H "Authorization: Bearer $KEY"

# Act on a ref from your latest observe.
curl -s -X POST https://unblock.sophtron.com/api/v1/sessions/$SESSION_ID/action -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" -d '{"action_type": "click", "action_parameters": {"ref": "e12"}}'

# Close when done; the session is deleted once its browser closes.
curl -s -X POST https://unblock.sophtron.com/api/v1/sessions/$SESSION_ID/close -H "Authorization: Bearer $KEY"
```

If an init response omits `sensitive_text_key`, never fall back to plaintext: close the session and init again. §5.1 shows how to use the key.

## 3. Reading an observe

When `state` is `ready`, observe returns:

- **`aria_snapshot`** (YAML): a `tabs:` header (index, title, url, active), then the accessibility tree, one element per line, for example `- button "Checkout" [ref=e12]`. Elements you can act on carry `[ref=eN]`; long runs of similar items may be folded.
- **`screenshot`** (`{media_type, data}`): base64 of the visible page with each ref's box and label drawn on it. JPEG, or PNG when nothing is labeled. **This annotated screenshot is for your agent-side visual reasoning only. Never show it to the user.**
- **`clean_screenshot`** (`{media_type: image/jpeg, data}`): base64 JPEG of the same viewport without ref boxes or labels, capped at 1920 px wide with its aspect ratio preserved. **This is the only screenshot you may show to the user.**

**View the annotated `screenshot` if you can read images** — it shows layout, images and charts the tree can't, with overlays that connect them to refs. Don't read either image's base64 as text. Whenever you show a screenshot to your user, **show only `clean_screenshot`, never `screenshot`**.

```bash
curl -s -X POST https://unblock.sophtron.com/api/v1/sessions/$SESSION_ID/observe -H "Authorization: Bearer $KEY" > observe.json
jq -r '.data.aria_snapshot' observe.json
jq -r '.data.screenshot.data' observe.json | base64 -d > annotated_screenshot.jpg   # agent use only; .png if image/png
jq -r '.data.clean_screenshot.data' observe.json | base64 -d > clean_screenshot.jpg  # show this image to the user
```

Refs are renumbered on every observe: use only refs from your **latest** observe. Before the browser is ready, observe returns only `state` and `reason`. Once the browser closes, the session is deleted and calls return `session_not_found`.

## 4. Actions

`POST https://unblock.sophtron.com/api/v1/sessions/{session_id}/action` with `{"action_type": "…", "action_parameters": {…}}` (omit `action_parameters` for `back`, `forward` and `reload`):

| `action_type` | `action_parameters` | What it does |
|---|---|---|
| `click` | `ref` | Clicks the element; clicking an `option` selects it. |
| `input` | `ref`, `text` | Replaces the field's text (`text` ≤ 10,000 chars, may be empty). |
| `set_sensitive_text` | `ref`, `encrypted_text`, optional `key_id` | Decrypts a secret made with this session's key and types it. Only ciphertext is accepted; see §5.1. |
| `press_key` | `key` | `enter`, `tab`, `backspace`, `escape`, `arrowup`, `arrowdown`, `arrowleft`, `arrowright` or `ctrl+a`. |
| `scroll` | `delta_x`, `delta_y` | Scrolls by pixels: each −10,000…10,000, at least one non-zero; positive scrolls down or right. |
| `navigate` | `url` | Opens a URL and waits up to 30 s. It must stay inside the init URL's navigation scope (§6.4). |
| `back`, `forward`, `reload` | none | History navigation or reload. |
| `switch_tab` | `tab_index` | Switches to a tab from the `tabs:` header (`0` or more). |

Refs match `^e[0-9]+$` and come from your latest observe. Each action answers with a message, for example `{"success": true, "data": {"message": "Clicked button \"Checkout\"."}}`; observe again to see the page. There is no wait action: wait on your side before observing.

## 5. Secrets

Never send a password, one-time code, PIN, card number, API key or security answer as `input.text`. **Prefer a secure form filled by your sign-in/credential vault (§5.2) when your environment supports it.** Otherwise use `set_sensitive_text` (§5.1), or give the secure form to the user.

### 5.1 Typing secrets safely

Init returns this session's public key:

```json
"sensitive_text_key": {
  "algorithm": "RSA-OAEP-256",
  "key_id": "9f2c…",
  "public_key": "-----BEGIN PUBLIC KEY-----\n…\n-----END PUBLIC KEY-----\n",
  "max_plaintext_bytes": 446
}
```

Encrypt the UTF-8 bytes of the secret with RSA-OAEP using SHA-256 for both the OAEP digest and MGF1, with an empty label. The key is 4096-bit, so the ciphertext is exactly 512 bytes; send it as standard base64 with padding. The plaintext may be at most `max_plaintext_bytes` (446) bytes. `key_id` is optional but recommended: a stale key then fails clearly.

```bash
# OpenSSL 1.1.1+. secret.bin is exactly the UTF-8 bytes to type (no added newline).
jq -r '.data.sensitive_text_key.public_key' init.json > public.pem
KEY_ID=$(jq -r '.data.sensitive_text_key.key_id' init.json)
openssl pkeyutl -encrypt -pubin -inkey public.pem \
  -pkeyopt rsa_padding_mode:oaep -pkeyopt rsa_oaep_md:sha256 \
  -pkeyopt rsa_mgf1_md:sha256 -in secret.bin -out ciphertext.bin
base64 ciphertext.bin | tr -d '\n' > encrypted_text.txt
```

```js
// Node.js 18+
const { publicEncrypt, constants } = require('node:crypto');
const key = init.data.sensitive_text_key;              // this session's init response
const bytes = Buffer.from(secret, 'utf8');
if (bytes.length > key.max_plaintext_bytes) throw new Error('secret is too long');
const encrypted_text = publicEncrypt(
  { key: key.public_key, padding: constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' },
  bytes
).toString('base64');
```

```python
# Python 3 (cryptography)
import base64
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding

key = init["data"]["sensitive_text_key"]
data = secret.encode("utf-8")
if len(data) > key["max_plaintext_bytes"]:
    raise ValueError("secret is too long")
public_key = serialization.load_pem_public_key(key["public_key"].encode("ascii"))
encrypted_text = base64.b64encode(public_key.encrypt(
    data, padding.OAEP(mgf=padding.MGF1(hashes.SHA256()),
                       algorithm=hashes.SHA256(), label=None))).decode("ascii")
```

```bash
jq -n --arg ref "e7" --arg encrypted "$ENCRYPTED_TEXT" --arg key_id "$KEY_ID" \
  '{action_type: "set_sensitive_text",
    action_parameters: {ref: $ref, encrypted_text: $encrypted, key_id: $key_id}}' \
  | curl -s -X POST https://unblock.sophtron.com/api/v1/sessions/$SESSION_ID/action \
      -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" --data-binary @-
```

The action uses the same ref and text-field checks as `input`; only `ref`, `encrypted_text` and `key_id` are accepted. A `text` field or any other key is `400 invalid_request`. The key belongs to its session: a new session gets a new key, and another session's ciphertext fails. If you lost the key, close the session and init again. The website still receives the plaintext when it is typed; it never appears in your request.

### 5.2 Signing in with a secure form

**Prefer this flow when you have a built-in sign-in/credential vault and computer-use or browser-use tools.** With the user's permission, let the vault remember their credentials for reuse and fill the one-time form without exposing the secrets to you. If vault-assisted filling is unavailable, use `set_sensitive_text` when you can; use the user-filled form below when you cannot pass credentials through the API.

1. **Observe the login page** and note the refs of the fields.
2. **Create a one-time form** immediately (within 120 s of that observe):

```bash
curl -s -X POST https://unblock.sophtron.com/api/v1/sessions/$SESSION_ID/secure-inputs \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"title": "PSE login", "fields": [
        {"ref": "e3", "kind": "username"},
        {"ref": "e5", "kind": "password"}]}'
# {"success": true, "data": {"secure_input_id": "…", "state": "pending",
#   "expires_at": "…", "url": "https://…/secure-input?id=…#t=…"}}
```

`fields` accepts 1–4 entries with unique refs and unique kinds: `username`, `password`, `otp` or `text` (an optional `label` names a `text` field). `title` ≤ 100, `label` ≤ 60. The form uses the refs and target host from your latest observe; creating a new form cancels a pending one.

3. **Fill and submit the form.** Prefer doing this yourself with your vault and computer-use/browser-use tools: open `url` in the vault-enabled browser — **not the Sophtron session browser**; the session must stay on the target page. Select the user's saved login for the target site, let the vault fill the fields, check that they match the requested kinds, and press **Send credentials**. If no login is saved, ask the user to enter it through your vault's secure save/sign-in UI and, with their permission, remember it there for future use; don't ask them to paste credentials into chat or copy vault secrets into your context or logs. If you cannot drive a vault-enabled browser, give `url` to the user (it works on a phone): tell them the target host, that the link is one-time and expires in 5 minutes, that their entries are encrypted and never stored by Sophtron, and that they must press **Send credentials**. The URL is a bearer capability: don't log it or post it publicly.

4. **Check completion yourself.** Poll the secure-input status endpoint below instead of asking the user whether credentials were entered or submitted. Do not offer **Done / Still filling / Cancel** or ask the user to report back; the server status is authoritative. `pending` → wait and poll, about once a minute while waiting for a user; if they are filling manually and it remains pending, you may remind them to press **Send credentials**, but keep checking yourself. `applying` → wait a few seconds and poll; `applied` → continue; `failed` → read the message, observe, and decide whether to create a new form; `expired`/`cancelled` → tell the user and create a new form when they are ready.

```bash
curl -s https://unblock.sophtron.com/api/v1/sessions/$SESSION_ID/secure-inputs/$SECURE_INPUT_ID \
  -H "Authorization: Bearer $KEY"
# {"success": true, "data": {"secure_input_id": "…", "state": "applied",
#   "expires_at": "…", "message": "Typed into textbox \"Username\" and textbox \"Password\"."}}
```

The submit is accepted immediately as `applying`; poll until `applied` or `failed`. While the form is `pending` or `applying`, observe and action return `409 secure_input_pending`; don't observe to "check", because a new snapshot renumbers refs. `DELETE /sessions/{id}/secure-inputs/{secure_input_id}` cancels a **pending** form and unblocks the session. After `applied`, observe if the page changed, then continue the sign-in.

The page encrypts each value in the browser with the session's key (RSA-OAEP-SHA256, one 512-byte ciphertext per field) and posts only ciphertext; the worker decrypts it and types it. The plaintext never enters your context, the API, or logs; the website still receives it. The form accepts at most 300 bytes of plaintext across all fields.

## 6. API reference

All paths are relative to `https://unblock.sophtron.com/api/v1`. §6.5 covers the token-only endpoints used by the form page; agents must not call those.

### 6.1 Conventions

- **Auth.** `Authorization: Bearer <api_key>` on every call except `POST /agents/register` and the form-page endpoints. Missing, invalid, disabled or expired keys answer `401 unauthorized`.
- **JSON.** Request bodies are JSON (`Content-Type: application/json`), capped at 64 KiB. Field names are snake_case. Null optional fields are omitted.
- **Envelopes.** Success `{"success": true, "data": {…}}`; error `{"success": false, "error": {"code": "…", "message": "…"}}`.
- **IDs and timestamps.** IDs are UUID strings. Timestamps are ISO 8601 with an offset, for example `2026-10-21T16:15:40.471+00:00`.
- **Statuses.** `200` for reads, close, delete, secure-input status/cancel; `201` for register, init, secure-input create. Errors use §7; `rate_limited` (429) and `no_capacity` (503) send `Retry-After` seconds, `session_limit` does not.
- **Unknown fields.** Most endpoints ignore unknown body properties; secure-input create is strict and rejects unknown or duplicate properties.

### 6.2 Endpoints

| Method | Path | Auth | Success |
|---|---|---|---|
| POST | `/agents/register` | none | 201 |
| POST | `/sessions` | agent | 201 |
| GET | `/sessions` | agent | 200 |
| POST | `/sessions/{session_id}/observe` | agent | 200 |
| POST | `/sessions/{session_id}/action` | agent | 200 |
| POST | `/sessions/{session_id}/close` | agent | 200 |
| DELETE | `/sessions/{session_id}` | agent | 200 |
| POST | `/sessions/{session_id}/secure-inputs` | agent | 201 |
| GET | `/sessions/{session_id}/secure-inputs/{secure_input_id}` | agent | 200 |
| DELETE | `/sessions/{session_id}/secure-inputs/{secure_input_id}` | agent | 200 |

`{session_id}` and `{secure_input_id}` are UUIDs. Field lists below are the `data` object. Failures use §7.

**`POST /agents/register`** (no auth; 5 registrations per IP per hour by default): request `name` (required; 1–64 chars: letters, digits, space, `.`, `_`, `-`) and `description` (optional; ≤ 500); response `agent_id`, `api_key` (shown once), `expires_at` (`30` days).

**`POST /sessions`** (init): request `url` (required; http/https, ≤ 2048 chars, no local or private hosts; `https://` is added when there is no scheme) and omit `session_id` (sessions can't be reopened; any non-empty value is `400 invalid_request`); response `session_id`, `state` (`creating`), `expires_at` (run deadline, 30 min by default), `sensitive_text_key` (§6.3; if omitted, init again).

**`GET /sessions`**: response `sessions: [{session_id, state, last_used_at}]`, this agent's open sessions, most recently used first.

**`POST /sessions/{session_id}/observe`** (no body; it takes a fresh snapshot and renumbers refs): response `session_id`, `state`, optional `reason` (when the run ends or fails, for example `agent_closed`, `max_lifetime`, `launch_failed`, `browser_crashed`, `worker_lost`), `aria_snapshot` (only when ready), `screenshot: {media_type: image/jpeg|image/png, data: base64}` (annotated, agent use only; only when ready), and `clean_screenshot: {media_type: image/jpeg, data: base64}` (unannotated and at most 1920 px wide, for showing to the user; only when ready).

**`POST /sessions/{session_id}/action`**: body `action_type` + `action_parameters`; the types and parameters are in §4; response `message`.

**`POST /sessions/{session_id}/close`** (no body): response `session_id`, `state` (`closing`, or the stored final state).

**`DELETE /sessions/{session_id}`**: closes a running browser, waits for it to end, deletes the session and frees the slot; response `deleted: true`.

**`POST /sessions/{session_id}/secure-inputs`** (ready session; observe within 120 s): request `title` (optional; ≤ 100; default `Secure sign-in`) and `fields` (1–4; each `ref` (required; `^e[0-9]+$`, ≤ 16), `kind` (required; `username`, `password`, `otp` or `text`; unique), `label` (optional; ≤ 60)); response `secure_input_id`, `url` (one-time; token in the fragment), `state` (`pending`), `expires_at` (5 min, capped by session expiry).

**`GET /sessions/{session_id}/secure-inputs/{secure_input_id}`**: response `secure_input_id`, `state` (`pending`, `applying`, `applied`, `failed`, `expired` or `cancelled`), `expires_at`, optional `error_code` (set when `failed`), optional `message`.

**`DELETE /sessions/{session_id}/secure-inputs/{secure_input_id}`**: cancels only a `pending` form; response `secure_input_id`, `state` (`cancelled`).

### 6.3 Common objects

**Session states:** `creating` → `launching` → `ready` → `closing` → `closed`, or `failed`. Before ready, observe returns only `session_id`, `state` and usually `reason`.

**Secure-input states:** `pending` → `applying` → `applied`/`failed`; or `expired`/`cancelled`.

**`sensitive_text_key`:** `algorithm` (`RSA-OAEP-256`), `key_id`, `public_key` (SPKI PEM), `max_plaintext_bytes` (446).

### 6.4 Navigation scope

The init `url` declares the session's target site. A `navigate` action is accepted only when its host is the init URL's **site domain** or a subdomain of it. The site domain is the registrable domain (eTLD+1), except for shared-hosting suffixes in the Public Suffix List's private section (for example `github.io` or `herokuapp.com`), where the suffix itself is the site domain — so `foo.github.io` allows sibling sites such as `other.github.io`.

| Init URL | Allowed `navigate` hosts | Not allowed |
|---|---|---|
| `https://www.example.com/` | `example.com`, `www.example.com`, `secure.example.com` | `example.org`, `example.com.evil.com` |
| `https://login.bank.co.uk/` | `bank.co.uk`, `secure.bank.co.uk` | `other.co.uk`, `co.uk` |
| `https://foo.github.io/` | `github.io`, `foo.github.io`, `bar.foo.github.io`, `other.github.io` | `github.com`, `example.com` |
| `https://93.184.216.34/` | `93.184.216.34` only | any other host |

Scheme and port don't matter. An IP init URL allows only that exact host. An ICANN public suffix as the init host (for example `co.uk`) also allows only that exact host; a private/shared-hosting suffix as the init host (for example `github.io`) allows the whole suffix. A rejected navigate is `403 navigation_not_allowed` and names the allowed domain. The check applies to `navigate`; links and redirects the page itself follows are not rewritten.

### 6.5 Secure-form page endpoints (not for agents)

The form page sends the one-time token from its URL fragment in `X-Secure-Input-Token`; the token is never accepted from the path, query or body. Agents must not call these.

| Method | Path | Request | Response |
|---|---|---|---|
| GET | `/secure-inputs/context` | Header token. | `200` with `secure_input_id`, `state`, `expires_at`; while pending also `title`, `site_host`, `fields[{ref,kind,label,autocomplete}]`, `key{algorithm,key_id,public_key,public_key_spki_der_base64,max_plaintext_bytes}` and `max_total_plaintext_bytes`; optional `error_code`/`message`. |
| POST | `/secure-inputs/submit` | Header token; `{key_id?, fields:[{ref, encrypted_text}]}` (1–4 refs, one 512-byte ciphertext each). | `202 {state: "applying", message}`. |

Both are rate-limited per IP at 120 requests per minute by default.

## 7. Errors

Errors look like `{"success": false, "error": {"code": "…", "message": "…"}}`; the message says what to do. `429 rate_limited` and `503 no_capacity` send `Retry-After` seconds.

| `code` | HTTP | What to do |
|---|---|---|
| `stale_ref`, `ref_not_found` | 422 | Observe again and use a ref from the new snapshot. |
| `element_not_actionable` | 422 | Observe to see why: the element may be covered or hidden, or the page changed. |
| `sensitive_text_decrypt_failed` | 422 | The ciphertext isn't for this session's key, or it's damaged. Re-encrypt with this session's `public_key` and `key_id`; if you no longer have them, close the session and start a new one. Never fall back to `input.text`. |
| `session_not_found` | 404 | The session doesn't exist or has already been deleted; start a new one. |
| `session_not_ready` | 409 | The browser is still starting; observe until `state` is `ready`. |
| `session_busy` | 409 | Wait for your previous call on this session to return. |
| `session_closed` | 409 | The browser has closed or is closing; start a new session if you need one. |
| `session_limit` | 429 | You have no free session slot; close or delete one of your open sessions. |
| `no_capacity` | 503 | No browser is free right now; retry after the `Retry-After` seconds. |
| `browser_timeout` | 504 | Observe to see where the page is. |
| `secure_input_pending` | 409 | A secure form is already open for this session. Poll the secure-input status endpoint until it is applied or failed, or delete it to cancel. Observe and action are blocked while it is pending. |
| `secure_input_used` | 409 | This form was already submitted. If you need to try again, create a new form from a fresh observe. |
| `secure_input_expired`, `secure_input_cancelled` | 410 | The form is no longer usable. Observe again and create a new secure form. |
| `secure_input_not_found` | 404 | The form doesn't exist or has expired; observe again and create a new secure form. |
| `invalid_request`, `invalid_url` | 400 | Fix the request as the message says. A `text` field in `set_sensitive_text` is refused; encrypt the secret instead. A secure-form create accepts only `title`, `fields`, `ref`, `kind` and `label`. |
| `unauthorized` | 401 | Check your `Authorization` header. |
| `navigation_not_allowed` | 403 | The URL is outside this session's navigation scope. Use the init URL's domain or a subdomain, or start a new session with the desired URL. |
| `rate_limited` | 429 | Too many requests from your address; wait for the `Retry-After` seconds. |
| `internal_error` | 500 | Retry the call; if it keeps failing, close the session and start a new one. |

## 8. Sessions, limits and rules

- **A session is one running browser.** Each is fresh: it keeps its own profile, but nothing is saved between sessions. It is deleted when its browser closes for any reason; you can't reopen it, so start a new one with `POST /sessions`.
- **The sensitive-text key belongs to its session.** It is returned only at init, and a new session gets a new key. Keep it for the session's life; don't reuse a key or ciphertext across sessions.
- **One secure form per session.** A new create replaces a pending form; the old URL stops working.
- **Runs last up to 30 minutes** (`expires_at`). Then the browser closes and the session is deleted.
- **Hold up to 3 open sessions.** `GET https://unblock.sophtron.com/api/v1/sessions` lists them with `state` and `last_used_at`; `DELETE https://unblock.sophtron.com/api/v1/sessions/{session_id}` closes one and frees its slot.
- **Rules:** send your key only to `https://unblock.sophtron.com`; init with the site you intend to visit (it defines the navigation scope); never send a secret as plaintext; give a secure-form URL only to the vault or the user, never log or publish it, and never navigate the Sophtron session browser to it; make one call at a time per session; use only refs from your latest observe; wait on your side when a page needs time; close sessions when done.
