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
- Localhost only — WebSocket connections restricted to 127.0.0.1 / ::1
- JSON required — POST/PATCH requests must use
Content-Type: application/json - Body limit — Maximum 5 MB request body
- Single browser — Only one browser tab connected at a time (new connection replaces previous)
- Port config — Default 4000, override with
ECBYTS_PORTenvironment variable
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
| Surface | Path prefix | Stability | Consumer |
|---|---|---|---|
| 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 family | Credential | Header | On failure |
|---|---|---|---|
/api/metering/*, /api/telemetry/* | Supabase JWT or ecbyts API key | Authorization: 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-key | API key in body | {"apiKey":"..."} | 400 {"valid":false,"error":"apiKey is required"}; unknown key → 200 {"valid":false}; auth backend missing → 503. |
/api/v1/ecbt | None (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 token | Authorization: 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 negatives | X-ECBYTS-Control-Token | No 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/upload | Dev gate | ECBYTS_DEV=1 on the server | 403 {"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.
| Prefix | Success | Error |
|---|---|---|
/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:
| Layer | Raised by | HTTP | Shape |
|---|---|---|---|
| Transport / request | The route wrapper, before the engine runs | 400, 503, 500 | { "ok": false, "kind": "error", "error": { "code": "bad_args" | "not_built" | "internal", "message": string, "hint"?: string } } |
| Domain | The ECBT engine, after dispatch | 200 (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).
| Aspect | Behaviour |
|---|---|
Access-Control-Allow-Origin | The server's own origin only (http://<host>:<port>). It is never *. |
OPTIONS preflight | 204, with Allow-Methods: GET, POST, PATCH, DELETE, OPTIONS and Allow-Headers: Content-Type, Authorization, X-API-Key, X-ECBYTS-Control-Token, X-ECBYTSADMIN-PASSWORD. |
| Port | ECBYTS_PORT (default 4000). The allowed CORS origin is derived from it, so the origin changes with the port. |
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" } }.
Request
{ "fn": "shannon", "args": [ "[10, 20, 30]" ] }
{ "fn": "table", "key": "dataset-id" }
fnis 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 HTTP200andok:false(the spreadsheet cell decides); transport/build errors use503/500. - Tabular primitives (
table,sum,value,meta) answerkey_not_founduntil a dataset loader is wired — documented stub, not a hidden fake.
Limits, pagination and negative cases
| Rule | Value | Observed response |
|---|---|---|
| Request body | 5 MB max (20 MB on /api/import and pipeline import), Content-Type: application/json required | Over 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. |
| Pagination | Not implemented on any route — list endpoints return the full array. | No page/cursor parameters are honored. |
| Rate limit | Only POST /api/pipelines/:id/run (one run per pipeline at a time). | 409 |
| Unknown route | — | 404 { "success": false, "error": "Not found" } |
| Metering write | Not 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 metering | The 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 idempotency | GET ?meta=functions is pure: repeated calls return a byte-identical envelope. | 200, same body every time. |
| Version negotiation | Only /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:
| Capability | State |
|---|---|
| Creating an environmental data package over the API | Does 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 API | Does 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. |
| Pagination | Not 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 keys | Not 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 / quotas | Not implemented beyond the single-run lock on POST /api/pipelines/:id/run (409). |
| Dataset loader behind the tabular primitives | Stub. 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
Response
{
"success": true,
"data": {
"browserConnected": true,
"uptime": 142,
"actionsCount": 196,
"pendingRequests": 0
}
}
Returns the complete model state as built by buildModel(). Equivalent to exporting the model as JSON.
Response
{
"selectedElement": "el_abc123",
"elementCount": 12,
"campaignCount": 3,
"sceneCount": 2,
"language": "en",
"activeRibbonTab": "modeling"
}
Runs a visual audit of the UI. Checks icon hydration, font families, open modals, button styling, theme variables, and form input classes.
Elements
Returns all elements with their data (excluding Three.js mesh references). Each element has: id, family, name, visible, label, color, data, stamps.
Parameters
| Param | Type | Description |
|---|---|---|
:id | string | Element ID |
Request Body
{ "familyId": "well" }
Valid family IDs: plume, well, lake, river, spring, building, tank, waste, boundary, sensor, or any custom family.
Request Body
{
"updates": {
"name": "MW-01",
"coordinates": { "easting": 350000, "northing": 7400000 }
}
}
Removes the element and all associated observations, stamps, and edges.
Returns the observations[] array from the element's data.
Returns governance, context, and reporting stamps attached to the element.
Returns all edges (incoming + outgoing) connected to the element.
Campaigns
Returns all campaigns with their date, team, and visibility state.
Creates a new sampling campaign with default values. No request body required.
Request Body
{
"field": "name",
"value": "Q1 2026 Sampling"
}
Removes the campaign. Observations linked to this campaign are NOT removed.
Scenes
Returns all saved scene (camera view) snapshots.
Creates a new scene with default camera position.
Removes the saved camera view.
Families
Returns built-in and custom families with id, name, icon, code, enabled state.
Request Body
{ "id": "piezometer", "name": "Piezometer", "icon": "gauge" }
Removes a custom family. Built-in families cannot be deleted.
Edges
Returns all edges with source, target, type, and metadata.
Parameters
| Param | Type | Description |
|---|---|---|
:id | string | Edge ID |
Stamps
Returns stamps grouped by element. Each entry includes elementId, elementName, family, and stamps[].
Groups
Returns the full groups state: elementGroups[], familyGroups[], elementGroupMap, familyGroupMap.
Returns a single group object with id, name, color, collapsed, order.
Auth
Response
{
"user": { "email": "user@example.com", "provider": "google" },
"loggedIn": true,
"email": "user@example.com",
"role": "admin",
"observerMode": "disabled",
"accessControlActive": true,
"owner": "admin@example.com"
}
Returns the array of access rules. Each rule has: email, role (owner/admin/editor/viewer/observer), areas[].
Returns the complete access configuration: owner, observerMode, rules[].
Ticker
Returns the full ticker config: visible, speed, separator, items[]. Each item has filters[], calculation, label, unitId, precision, color.
Returns the live computed values for all enabled ticker items. Each entry has: id, text, color.
SAO Protocol
Response
{
"active": true,
"scenario": "tailings_dam",
"tier": "recommended",
"matrices": ["agua", "solo", "ar"],
"parameterCounts": { "agua": { "essential": 15, "recommended": 28, "total": 45 } }
}
Returns all currently active parameters based on the selected scenario, tier, and matrices. When SAO is inactive, returns all CONFIG.PARAMETERS.
Governance
Returns the governance section of the model: contracts, WBS items, parties, disbursements, and EVA calculations. Returns null if no governance data exists.
Sensors
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
Returns all agents (6 system + user-created). Each agent has: id, name, description, systemPromptAddition, system (boolean).
Response
{ "id": "default" }
Returns the agent object. System agents: default, regulatory-br, regulatory-us, regulatory-intl, campaign, hse.
Modals
Returns visible modal overlays with their ID, title, and structure info.
Returns detailed structure: header, body, footer presence, button audit, dimensions.
Closes all visible modals including SAO, stamp, and edge modals.
Panels
Returns panel visibility, docking state, and dimensions for left, right, and analytics panels.
Chat
Query Parameters
| Param | Default | Description |
|---|---|---|
last | all | Return only the last N messages |
Returns the conversation history from the AI assistant chat panel.
Notifications
Returns all currently visible toast notifications with their text, type (success, error, warning, info), and timestamp.
Tabs
Request Body
{ "tabId": "analysis" }
Switches the active ribbon tab. Common tab IDs: modeling, analysis, tools, view.
Export / Import
Returns the full model state as a JSON object.
Returns the model encoded as an ECBT key string. Timeout: 30 seconds.
Request Body
{ "model": { /* full model object */ } }
Request Body
{ "key": "ECBT.A0000001..." }
Screenshot
Query Parameters
| Param | Default | Description |
|---|---|---|
mode | full | full = entire page, 3d = canvas only |
Returns PNG image binary (Content-Type: image/png).
DOM & Eval
Query Parameters
| Param | Required | Description |
|---|---|---|
selector | Yes | CSS selector (e.g. .panel, #app) |
props | No | Comma-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.
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 () => { ... })().
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
Returns all registered pipelines with their ID, name, steps, and status.
Request Body
{
"id": "my-pipeline",
"name": "Data Quality Check",
"steps": [...]
}
Saves a pipeline definition. If id matches an existing pipeline, it is updated.
Parameters
| Param | Type | Description |
|---|---|---|
:id | string | Pipeline ID |
Parameters
| Param | Type | Description |
|---|---|---|
:id | string | Pipeline ID |
Parameters
| Param | Type | Description |
|---|---|---|
:id | string | Pipeline ID |
Executes the pipeline steps sequentially. Rate limited: returns 409 Conflict if the pipeline is already running. Timeout: 30s.
EcoTools
Query Parameters
| Param | Required | Description |
|---|---|---|
id | Yes | EcoTool identifier |
Response
{
"success": true,
"data": {
"id": "tool-123",
"name": "Soil Analysis",
"records": [...]
}
}
Aerial Recognition
Query Parameters
| Param | Default | Description |
|---|---|---|
ai | 0 | Set to 1 to include AI-powered analysis |
resolution | 512 | Image resolution for processing |
Runs diagnostic analysis on the aerial/satellite image. Timeout: 30s.
Query Parameters
| Param | Default | Description |
|---|---|---|
method | algorithm | algorithm, ml, or ai |
resolution | 512 | Image resolution for processing |
Request Body (optional)
{ "calibration": { ... } }
Runs aerial recognition and automatically imports detected features as elements. Timeout: 30s.
Query Parameters
| Param | Default | Description |
|---|---|---|
resolution | 512 | Image resolution for calibration |
Auto-calibrates recognition thresholds from the boundary image. Timeout: 30s.
Removes all elements that are not of the boundary family. Used to clean up after recognition runs.
Returns raw SegFormer model output for debugging purposes. Timeout: 30s.
LLM
Returns the current LLM provider, model, and connection status.
Request Body
{
"provider": "openai",
"apiKey": "sk-...",
"model": "gpt-4o"
}
All fields are optional. Only provided fields are updated.
Tests the LLM connection with the current configuration. Timeout: 30s.
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
Returns the ACTION_REGISTRY — array of all registered actions with name, params[], and cat (category).
Request Body
{
"action": "handleAddObservation",
"args": ["el_abc123"]
}
Executes any window.* function matching the handler pattern. The args array is spread as function arguments.
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
| Type | Direction | Description |
|---|---|---|
ready | Browser → Server | Browser connected, sends list of available actions |
command | Server → Browser | Execute command (with requestId) |
response | Browser → Server | Command result (with matching requestId) |
reload | Server → Browser | Force page reload |
Timeouts
- Standard API calls: 10 seconds
- Async operations (LLM, sensor, import): 30 seconds
- Heartbeat: 30 seconds ping interval
- Reconnect: Up to 50 attempts, 2 second delay
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
| Status | Meaning | Common Causes |
|---|---|---|
200 | Success | Request completed. Check success field in response. |
400 | Bad Request | Missing required field (e.g. familyId, action) |
404 | Not Found | Unknown endpoint or element/resource ID not found |
405 | Method Not Allowed | Wrong HTTP method for endpoint (e.g. DELETE on GET-only route) |
413 | Payload Too Large | Request body exceeds 5 MB limit |
500 | Server Error | Internal error in server or browser command execution |
503 | Service Unavailable | Browser not connected to WebSocket |
504 | Gateway Timeout | Browser command did not respond within timeout (10s / 30s) |
Error Response Format
{
"success": false,
"error": "Browser not connected"
}