Getting Started

The ecbyts Automation API provides programmatic access to the Environmental Digital Twin application. It enables external tools, scripts, and AI agents to control the application running in the browser via REST endpoints and WebSocket commands.

Quick Start

# Start the API server
npm run api

# Check server health
curl http://localhost:4000/api/health

# List all available actions
curl http://localhost:4000/api/actions

# Generate a random model
curl -X POST http://localhost:4000/api/action \
  -H "Content-Type: application/json" \
  -d '{"action":"generateRandomModel"}'

Security

Scope note - monetization endpoints such as API key validation, telemetry, metering, library checkout, marketplace, and payment webhooks are documented separately in ecbytsadmin monetization docs.

Integration Contract (v1)

This section is the versioned contract for programmatic integration (RF-18). It documents what the server actually implements today; nothing here is a roadmap. Every statement is enforced by tests/env-18-api-contract-v1.test.mjs, which exercises the real route handlers (scripts/lib/api-ecbt.js, scripts/lib/api-telemetry.js) and the source of scripts/api-server.js.

Versioning

SurfacePath prefixStabilityConsumer
Integration API/api/v1/*Versioned. Breaking changes require a new prefix (/api/v2/); fields are only added, never renamed or removed, inside v1.External tools (Excel add-in, scripts, agents).
Local automation bridge/api/* (no version segment)Unversioned, loopback-only. Mirrors the in-browser action registry and may change with the app. Not an integration contract.Local development / test harness only.

Today the only v1 resource is /api/v1/ecbt (ECBT Universal API, server tier). There is no X-API-Version header: the version lives in the path.

Authentication

Route familyCredentialHeaderOn failure
/api/metering/*, /api/telemetry/*Supabase JWT or ecbyts API keyAuthorization: Bearer <jwt> or X-API-Key: <key>401 {"success":false,"error":"Authentication required"}; 503 {"success":false,"error":"Auth service not configured"} when the auth backend is not configured.
POST /api/auth/validate-keyAPI key in body{"apiKey":"..."}400 {"valid":false,"error":"apiKey is required"}; unknown key → 200 {"valid":false}; auth backend missing → 503.
/api/v1/ecbtNone (loopback)—Runs only on the loopback server; a public deployment MUST front it with the API-key check above. This is a documented gap, not an implemented feature.
Supabase /functions/v1/ecbt-api (Excel: value/table/sum)Verified Supabase user access tokenAuthorization: Bearer <access_token>; a publishable apikey alone is not authentication.Missing/invalid identity → 401; a caller not authorized for the shared model, an expired/revoked share, or a share code the caller cannot see → 403 {"ok":false,"error":{"code":"access_denied"}} (not-yours and nonexistent are deliberately indistinguishable). The share row and the model owner are read as the caller (its own Bearer, so Postgres RLS applies); the service role reads only the final snapshot, after authorization. Operator kill-switch: the function secret ECBT_API_AUTH_MODE defaults to enforce; only the exact value legacy-open re-admits anonymous POSTs (responses then carry x-ecbt-auth-mode: legacy-open and GET reports authMode) — any other value stays enforced. A share code locates a resource; it does not grant identity. Excel formulas keep their signatures; authenticate through the task pane. Office Scripts require a user access token at execution, never a service-role key or a token stored in workbook cells.
Sensitive bridge routes (/api/dom, /api/eval, /api/reload, /api/llm/*, /api/pdfplumber, POST /api/action, GET /api/ecotool-records)Local HTML session cookie and control token — two gates, two different negativesX-ECBYTS-Control-TokenNo session → 401 {"success":false,"error":"Local control session required. Open the app on this api-server once before calling this endpoint."}. Session but missing/wrong token → 403 {"success":false,"error":"Valid X-ECBYTS-Control-Token header required for this local control endpoint."}. A caller that never opened the app gets 401, never 403.
POST /api/files/uploadDev gateECBYTS_DEV=1 on the server403 {"success":false,"error":"/api/files/upload requires dev gate"}

Response envelopes (schemas)

Two envelopes coexist. Consumers must branch on the path prefix, never on field sniffing.

PrefixSuccessError
/api/v1/ecbt{ "ok": true, "kind": "catalog" | "science" | "value" | "table" | ..., "data": ..., "meta"?: {...} }Two layers — see below.
/api/* (bridge){ "success": true, "data": ... }{ "success": false, "error": string }

Errors on /api/v1/ecbt come in two layers, and they do not have the same shape. A consumer must branch on ok, never on kind, because kind is absent in the second layer:

LayerRaised byHTTPShape
Transport / requestThe route wrapper, before the engine runs400, 503, 500{ "ok": false, "kind": "error", "error": { "code": "bad_args" | "not_built" | "internal", "message": string, "hint"?: string } }
DomainThe ECBT engine, after dispatch200 (the spreadsheet cell decides how to render it){ "ok": false, "error": { "code": "unknown_function" | "key_not_found" | ..., "message": string, "hint"?: string } } — no kind field

Worked example of the difference: POST {} (no fn) answers 400 {"ok":false,"kind":"error","error":{"code":"bad_args","message":"Field \"fn\" (function id) is required."}}, while POST {"fn":"zzz"} answers 200 {"ok":false,"error":{"code":"unknown_function","message":"Unknown ecbt function: zzz","hint":"Use ecbt.listFunctions() to discover available functions."}}.

Transport, CORS and preflight

The bridge binds to loopback only (127.0.0.1, ::1 or localhost; any other ECBYTS_HOST aborts the process at startup) and refuses to start at all without an explicit dev gate (NODE_ENV=development|test, ECBYTS_DEV=1 or ECBYTS_CI=1).

AspectBehaviour
Access-Control-Allow-OriginThe server's own origin only (http://<host>:<port>). It is never *.
OPTIONS preflight204, with Allow-Methods: GET, POST, PATCH, DELETE, OPTIONS and Allow-Headers: Content-Type, Authorization, X-API-Key, X-ECBYTS-Control-Token, X-ECBYTSADMIN-PASSWORD.
PortECBYTS_PORT (default 4000). The allowed CORS origin is derived from it, so the origin changes with the port.
GET /api/v1/ecbt?meta=functions Discovery catalog (primitives + public science functions) ▶

Idempotent, read-only. Returns every function the POST accepts. Not paginated: the full array is returned (catalog size is bounded by the build).

{
  "ok": true,
  "kind": "catalog",
  "data": { "functions": [ { "id": "table", "category": "primitive", "params": ["key"], "arity": 1, "description": "Read a dataset as a 2D matrix." }, ... ] }
}

When the engine is not built the answer is honest: 503 { "ok": false, "kind": "error", "error": { "code": "not_built", "hint": "app/js/core/api/ecbtScienceRegistry.js" } }.

POST /api/v1/ecbt Invoke an ECBT function {fn, key?, args?} ▶

Request

{ "fn": "shannon", "args": [ "[10, 20, 30]" ] }
{ "fn": "table", "key": "dataset-id" }
  • fn is required — missing/blank → 400 { "ok": false, "kind": "error", "error": { "code": "bad_args" } }.
  • Arguments are coerced to the spreadsheet string model (numbers/booleans/objects are stringified) before reaching the engine.
  • Idempotency: the call is a pure computation — the same {fn,args} returns the same envelope; there is no server-side state to duplicate. Domain errors are returned with HTTP 200 and ok:false (the spreadsheet cell decides); transport/build errors use 503/500.
  • Tabular primitives (table, sum, value, meta) answer key_not_found until a dataset loader is wired — documented stub, not a hidden fake.

Limits, pagination and negative cases

RuleValueObserved response
Request body5 MB max (20 MB on /api/import and pipeline import), Content-Type: application/json requiredOver the limit → 413 {"success":false,"error":"Payload too large"} (the stream is destroyed mid-flight, so the limit is enforced on bytes received, not on Content-Length); wrong content type → 415 {"success":false,"error":"Content-Type must be application/json"}; malformed JSON → 400 {"success":false,"error":"Invalid JSON in request body"}. All three use the bridge envelope even on /api/v1/*, because they are raised before routing.
PaginationNot implemented on any route — list endpoints return the full array.No page/cursor parameters are honored.
Rate limitOnly POST /api/pipelines/:id/run (one run per pipeline at a time).409
Unknown route—404 { "success": false, "error": "Not found" }
Metering writeNot idempotent — each accepted POST /api/metering/record increments usage.400 {"success":false,"error":"userId and tier are derived from authentication"} when the body tries to set them.
Validation order on meteringThe metric name is checked before the credential.An unknown metric answers 400 {"success":false,"error":"invalid metric"} even with no credential at all — so a 400 here does not mean you were authenticated.
/api/v1/ecbt read idempotencyGET ?meta=functions is pure: repeated calls return a byte-identical envelope.200, same body every time.
Version negotiationOnly /api/v1/* exists. There is no X-API-Version header and no content negotiation.GET /api/v2/ecbt → 404 {"success":false,"error":"Not found"}, like any unknown route.

Declared gap — what v1 is not

This contract is deliberately scoped, and the boundary is stated here rather than left to be discovered. What exists today: one versioned read/compute resource, /api/v1/ecbt, served by the loopback bridge; authenticated metering, telemetry and API-key validation backed by Supabase; the negative and limit behaviour tabulated above.

What does not exist — each of these is a gap, not an undocumented feature:

CapabilityState
Creating an environmental data package over the APIDoes not exist. There is no create/update resource in v1 on any surface. /api/v1/ecbt reads and computes; it stores nothing.
Authenticated remote integration APIDoes not exist. /api/v1/ecbt carries no credential check of its own; it relies on being reachable only over loopback. Any remote deployment of an integration API is out of scope for v1 and is not described by this contract.
Authorization negatives on /api/v1/ecbt (401/403)Do not exist — there is no authorization step on that route to refuse anything. The 401/403/503 cases documented above belong to metering, telemetry and the sensitive local-control routes, not to v1.
PaginationNot implemented anywhere. Every list response is complete; no page, cursor, limit or offset parameter is honoured, and none is silently ignored into a partial answer.
Write idempotency keysNot implemented. There is no Idempotency-Key header. Reads on v1 are naturally idempotent because they are pure; the one write path (metering) is explicitly not idempotent.
Rate limiting / quotasNot implemented beyond the single-run lock on POST /api/pipelines/:id/run (409).
Dataset loader behind the tabular primitivesStub. table, sum, value and meta answer key_not_found until a loader is wired — declared, not faked.

Scope note (RF-18 acceptance): the acceptance sentence "a test consumer creates/queries an authorized package, with negative responses and idempotency verified" is not satisfied, and this section says so on purpose. The half that is deliverable — a versioned contract, documented and enforced by tests — is above; the half that is not is the table immediately preceding this paragraph. An authenticated external API was postponed by an explicit product decision until a real integrator exists; until then this page is the contract, and the gap is the deliverable.

Health & Status

GET /api/health Server status & browser connection ▶

Response

{
  "success": true,
  "data": {
    "browserConnected": true,
    "uptime": 142,
    "actionsCount": 196,
    "pendingRequests": 0
  }
}
GET /api/state Full model state (elements, campaigns, scenes) ▶

Returns the complete model state as built by buildModel(). Equivalent to exporting the model as JSON.

GET /api/ui/state UI state (selected element, language, active tab) ▶

Response

{
  "selectedElement": "el_abc123",
  "elementCount": 12,
  "campaignCount": 3,
  "sceneCount": 2,
  "language": "en",
  "activeRibbonTab": "modeling"
}
GET /api/ui/consistency Visual consistency audit (icons, fonts, theme) ▶

Runs a visual audit of the UI. Checks icon hydration, font families, open modals, button styling, theme variables, and form input classes.

Elements

GET /api/elements List all elements ▶

Returns all elements with their data (excluding Three.js mesh references). Each element has: id, family, name, visible, label, color, data, stamps.

GET /api/elements/:id Get single element by ID ▶

Parameters

ParamTypeDescription
:idstringElement ID
POST /api/elements Create new element ▶

Request Body

{ "familyId": "well" }

Valid family IDs: plume, well, lake, river, spring, building, tank, waste, boundary, sensor, or any custom family.

PATCH /api/elements/:id Update element properties ▶

Request Body

{
  "updates": {
    "name": "MW-01",
    "coordinates": { "easting": 350000, "northing": 7400000 }
  }
}
DELETE /api/elements/:id Remove element ▶

Removes the element and all associated observations, stamps, and edges.

GET /api/elements/:id/observations Get element observations ▶

Returns the observations[] array from the element's data.

GET /api/elements/:id/stamps Get element stamps ▶

Returns governance, context, and reporting stamps attached to the element.

GET /api/elements/:id/edges Get element edges (relationships) ▶

Returns all edges (incoming + outgoing) connected to the element.

Campaigns

GET /api/campaigns List all sampling campaigns ▶

Returns all campaigns with their date, team, and visibility state.

POST /api/campaigns Create new campaign ▶

Creates a new sampling campaign with default values. No request body required.

PATCH /api/campaigns/:id Update campaign field ▶

Request Body

{
  "field": "name",
  "value": "Q1 2026 Sampling"
}
DELETE /api/campaigns/:id Remove campaign ▶

Removes the campaign. Observations linked to this campaign are NOT removed.

Scenes

GET /api/scenes List all saved camera views ▶

Returns all saved scene (camera view) snapshots.

POST /api/scenes Create new scene ▶

Creates a new scene with default camera position.

DELETE /api/scenes/:id Remove scene ▶

Removes the saved camera view.

Families

GET /api/families List all element families ▶

Returns built-in and custom families with id, name, icon, code, enabled state.

POST /api/families Create custom family ▶

Request Body

{ "id": "piezometer", "name": "Piezometer", "icon": "gauge" }
DELETE /api/families/:id Remove custom family ▶

Removes a custom family. Built-in families cannot be deleted.

Edges

GET /api/edges List all element relationships ▶

Returns all edges with source, target, type, and metadata.

GET /api/edges/:id Get single edge by ID ▶

Parameters

ParamTypeDescription
:idstringEdge ID

Stamps

GET /api/stamps All stamps across all elements ▶

Returns stamps grouped by element. Each entry includes elementId, elementName, family, and stamps[].

Groups

GET /api/groups All element and family groups ▶

Returns the full groups state: elementGroups[], familyGroups[], elementGroupMap, familyGroupMap.

GET /api/groups/:id Get single group by ID ▶

Returns a single group object with id, name, color, collapsed, order.

Auth

GET /api/auth/status Current authentication status ▶

Response

{
  "user": { "email": "user@example.com", "provider": "google" },
  "loggedIn": true,
  "email": "user@example.com",
  "role": "admin",
  "observerMode": "disabled",
  "accessControlActive": true,
  "owner": "admin@example.com"
}
GET /api/auth/rules Access control rules list ▶

Returns the array of access rules. Each rule has: email, role (owner/admin/editor/viewer/observer), areas[].

GET /api/auth/permissions Full permissions config ▶

Returns the complete access configuration: owner, observerMode, rules[].

Ticker

GET /api/ticker Ticker configuration and items ▶

Returns the full ticker config: visible, speed, separator, items[]. Each item has filters[], calculation, label, unitId, precision, color.

GET /api/ticker/values Current computed ticker values ▶

Returns the live computed values for all enabled ticker items. Each entry has: id, text, color.

SAO Protocol

GET /api/sao SAO protocol status ▶

Response

{
  "active": true,
  "scenario": "tailings_dam",
  "tier": "recommended",
  "matrices": ["agua", "solo", "ar"],
  "parameterCounts": { "agua": { "essential": 15, "recommended": 28, "total": 45 } }
}
GET /api/sao/parameters Active monitoring parameters ▶

Returns all currently active parameters based on the selected scenario, tier, and matrices. When SAO is inactive, returns all CONFIG.PARAMETERS.

Governance

GET /api/governance Contracts, WBS, and EVA data ▶

Returns the governance section of the model: contracts, WBS items, parties, disbursements, and EVA calculations. Returns null if no governance data exists.

Sensors

GET /api/sensors/:elementId Fetch live sensor data ▶

Fetches live data for a sensor-family element via its 3-stage pipeline (JSONPlaceholder, FakerAPI, OpenWeatherMap). Element must be of family sensor.

Timeout: 30 seconds (async operation)

Agents

GET /api/agents List all LLM agents ▶

Returns all agents (6 system + user-created). Each agent has: id, name, description, systemPromptAddition, system (boolean).

GET /api/agents/active Get active agent ID ▶

Response

{ "id": "default" }
GET /api/agents/:id Get single agent by ID ▶

Returns the agent object. System agents: default, regulatory-br, regulatory-us, regulatory-intl, campaign, hse.

Modals

GET /api/modals List open modals ▶

Returns visible modal overlays with their ID, title, and structure info.

GET /api/modals/:id Inspect modal DOM structure ▶

Returns detailed structure: header, body, footer presence, button audit, dimensions.

POST /api/modals/close-all Close all open modals ▶

Closes all visible modals including SAO, stamp, and edge modals.

Panels

GET /api/panels Get panel states (left, right, analytics) ▶

Returns panel visibility, docking state, and dimensions for left, right, and analytics panels.

Chat

GET /api/chat/messages Get LLM chat messages ▶

Query Parameters

ParamDefaultDescription
lastallReturn only the last N messages

Returns the conversation history from the AI assistant chat panel.

Notifications

GET /api/toasts Get visible toast notifications ▶

Returns all currently visible toast notifications with their text, type (success, error, warning, info), and timestamp.

Tabs

POST /api/tabs Switch active ribbon tab ▶

Request Body

{ "tabId": "analysis" }

Switches the active ribbon tab. Common tab IDs: modeling, analysis, tools, view.

Export / Import

GET /api/export/json Export model as JSON ▶

Returns the full model state as a JSON object.

GET /api/export/key Export model as ECO key ▶

Returns the model encoded as an ECBT key string. Timeout: 30 seconds.

POST /api/import/json Import model from JSON ▶

Request Body

{ "model": { /* full model object */ } }
POST /api/import/key Import model from ECO key ▶

Request Body

{ "key": "ECBT.A0000001..." }

Screenshot

GET /api/screenshot Capture screenshot as PNG ▶

Query Parameters

ParamDefaultDescription
modefullfull = entire page, 3d = canvas only

Returns PNG image binary (Content-Type: image/png).

DOM & Eval

GET /api/dom Query DOM elements ▶

Query Parameters

ParamRequiredDescription
selectorYesCSS selector (e.g. .panel, #app)
propsNoComma-separated: style, box, html, classes

Example

curl "localhost:4000/api/dom?selector=%23app&props=style,box"

Requires a local control session cookie plus X-ECBYTS-Control-Token. Bootstrap by opening http://localhost:4000/ and then calling GET /api/local-control/token.

POST /api/eval Evaluate JS expression in browser ▶

Request Body

{
  "expr": "document.title",
  "timeout": 5000
}

Evaluates a JavaScript expression in the browser context. Default timeout: 30s. Use for testing and automation — wrap async imports in (async () => { ... })().

POST /api/reload Force browser page reload ▶

Sends a reload signal to the connected browser tab. The WebSocket bridge reconnects automatically after reload.

Security note: /api/dom, /api/eval, and /api/reload now require both the local control session cookie and X-ECBYTS-Control-Token.

Pipelines

GET /api/pipelines List all BPMN pipelines ▶

Returns all registered pipelines with their ID, name, steps, and status.

POST /api/pipelines Create or update pipeline ▶

Request Body

{
  "id": "my-pipeline",
  "name": "Data Quality Check",
  "steps": [...]
}

Saves a pipeline definition. If id matches an existing pipeline, it is updated.

GET /api/pipelines/:id Get pipeline by ID ▶

Parameters

ParamTypeDescription
:idstringPipeline ID
DELETE /api/pipelines/:id Remove pipeline ▶

Parameters

ParamTypeDescription
:idstringPipeline ID
POST /api/pipelines/:id/run Execute pipeline ▶

Parameters

ParamTypeDescription
:idstringPipeline ID

Executes the pipeline steps sequentially. Rate limited: returns 409 Conflict if the pipeline is already running. Timeout: 30s.

EcoTools

GET /api/ecotool-records Get EcoTool output records ▶

Query Parameters

ParamRequiredDescription
idYesEcoTool identifier

Response

{
  "success": true,
  "data": {
    "id": "tool-123",
    "name": "Soil Analysis",
    "records": [...]
  }
}

Aerial Recognition

GET /api/aerial/diagnostic Run aerial diagnostic analysis ▶

Query Parameters

ParamDefaultDescription
ai0Set to 1 to include AI-powered analysis
resolution512Image resolution for processing

Runs diagnostic analysis on the aerial/satellite image. Timeout: 30s.

POST /api/aerial/recognize Run recognition and import elements ▶

Query Parameters

ParamDefaultDescription
methodalgorithmalgorithm, ml, or ai
resolution512Image resolution for processing

Request Body (optional)

{ "calibration": { ... } }

Runs aerial recognition and automatically imports detected features as elements. Timeout: 30s.

GET /api/aerial/auto-calibrate Auto-calibrate detection thresholds ▶

Query Parameters

ParamDefaultDescription
resolution512Image resolution for calibration

Auto-calibrates recognition thresholds from the boundary image. Timeout: 30s.

POST /api/aerial/clean Remove non-boundary elements ▶

Removes all elements that are not of the boundary family. Used to clean up after recognition runs.

GET /api/aerial/debug-segformer Debug SegFormer model output ▶

Returns raw SegFormer model output for debugging purposes. Timeout: 30s.

LLM

GET /api/llm Get LLM provider configuration ▶

Returns the current LLM provider, model, and connection status.

POST /api/llm Configure LLM provider ▶

Request Body

{
  "provider": "openai",
  "apiKey": "sk-...",
  "model": "gpt-4o"
}

All fields are optional. Only provided fields are updated.

POST /api/llm/test Test LLM connection ▶

Tests the LLM connection with the current configuration. Timeout: 30s.

PDF

POST /api/pdfplumber Extract tables from PDF ▶

Request Body

{
  "filePath": "C:/path/to/file.pdf",
  "pages": "1-5"
}

Response

{
  "success": true,
  "data": {
    "tables": [...],
    "meta": { "cache": "miss" }
  }
}

Extracts tables from PDF via Python pdfplumber. Requires Python 3 with pip install pdfplumber. Results cached for 15 minutes. Timeout: 30s.

Security note: /api/llm, /api/llm/test, and /api/pdfplumber now require the same cookie + token pair.

Actions

GET /api/actions List all available actions with metadata ▶

Returns the ACTION_REGISTRY — array of all registered actions with name, params[], and cat (category).

POST /api/action Execute a single action ▶

Request Body

{
  "action": "handleAddObservation",
  "args": ["el_abc123"]
}

Executes any window.* function matching the handler pattern. The args array is spread as function arguments.

POST /api/action/batch Execute multiple actions sequentially ▶

Request Body

{
  "actions": [
    { "action": "handleAddElement", "args": ["well"] },
    { "action": "handleAddElement", "args": ["plume"] }
  ],
  "stopOnError": true
}

WebSocket Protocol

The API uses WebSocket for real-time communication between the Node.js server and the browser tab.

Connection

# Connect from localhost only
ws://localhost:4000/ws

Message Types

TypeDirectionDescription
readyBrowser → ServerBrowser connected, sends list of available actions
commandServer → BrowserExecute command (with requestId)
responseBrowser → ServerCommand result (with matching requestId)
reloadServer → BrowserForce page reload

Timeouts

Actions Reference

All — registered actions available via POST /api/action. Loaded live from the server.

Loading actions from server...

Examples

Create a monitoring well and add an observation

# 1. Create a well
curl -X POST http://localhost:4000/api/elements \
  -H "Content-Type: application/json" \
  -d '{"familyId":"well"}'

# 2. Add observation to the well (use element ID from step 1)
curl -X POST http://localhost:4000/api/action \
  -H "Content-Type: application/json" \
  -d '{"action":"handleAddObservation","args":["el_abc123"]}'

# 3. Set the observation parameter to pH
curl -X POST http://localhost:4000/api/action \
  -H "Content-Type: application/json" \
  -d '{"action":"handleObservationParameterChange","args":["el_abc123",0,"ph"]}'

Generate random model and export

# Generate random model with all element types
curl -X POST http://localhost:4000/api/action \
  -H "Content-Type: application/json" \
  -d '{"action":"generateRandomModel"}'

# Export as JSON
curl http://localhost:4000/api/export/json -o model.json

# Export as ECO key
curl http://localhost:4000/api/export/key

Query module states

# Check auth status
curl http://localhost:4000/api/auth/status

# Get groups configuration
curl http://localhost:4000/api/groups

# Get ticker computed values
curl http://localhost:4000/api/ticker/values

# Get SAO protocol status
curl http://localhost:4000/api/sao

# List all LLM agents
curl http://localhost:4000/api/agents

Batch actions

curl -X POST http://localhost:4000/api/action/batch \
  -H "Content-Type: application/json" \
  -d '{
    "actions": [
      {"action": "handleAddElement", "args": ["well"]},
      {"action": "handleAddElement", "args": ["well"]},
      {"action": "handleAddElement", "args": ["plume"]},
      {"action": "handleAddCampaign"}
    ],
    "stopOnError": true
  }'

Error Codes

StatusMeaningCommon Causes
200SuccessRequest completed. Check success field in response.
400Bad RequestMissing required field (e.g. familyId, action)
404Not FoundUnknown endpoint or element/resource ID not found
405Method Not AllowedWrong HTTP method for endpoint (e.g. DELETE on GET-only route)
413Payload Too LargeRequest body exceeds 5 MB limit
500Server ErrorInternal error in server or browser command execution
503Service UnavailableBrowser not connected to WebSocket
504Gateway TimeoutBrowser command did not respond within timeout (10s / 30s)

Error Response Format

{
  "success": false,
  "error": "Browser not connected"
}