Base URL: the engine's link (e.g. http://localhost:4000 in development, https://<engine domain> in production).
The engine runs apps: a campaign (/campaign/:id) or a flow (/flow/:id: its trigger runs and Studio's tests). It keeps each app's config (mooflair's, pulled from /api/internal and patched when it changes) and its state (the scheduler's), and asks mooflair what each run needs when the run starts. mooflair never sends a run payload. See Runs › How a run starts.
Authentication
| Kind | Sent as | Who | Used for |
|---|---|---|---|
| server | Authorization: Bearer <ENGINE_SECRET> | mooflair's server | Apps (config, start, stop), runs, browsers, workspace jobs |
| user | Authorization: Bearer <JWT> (or ?token= on websockets) | mooflair's pages | Run lists, sockets, live links, stats, files |
| metrics | Authorization: Bearer <ENGINE_METRICS_TOKEN> | Prometheus | /metrics |
The workspace JWT is ES256, signed by mooflair with its private key after it checked the person's access, and checked by every engine with MOOFLAIR_KEY: { "sub": "<workspaceId>", "exp": <unix seconds> }. It reaches that workspace's runs and browsers. A campaign dashboard's adds "campaign": "<campaignId>" and "browser": ["<browserId>", …]: it reaches only that campaign's runs, logs, stats, socket and browsers.
The rule for paths
Anything that belongs to one owner sits under it: /workspace/:id/…, /campaign/:id/…, /flow/:id/…, /run/:id/…, /browser/:id/…. A user token must belong to that owner (its workspace, or for a dashboard token its campaign). Only admin lists that span owners are flat (/run, /log, /app). Filters that aren't the owner stay in the query: ?status=, ?dry=, ?before=, ?limit=, ?days=.
Summary
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | /workspace/:id/campaign | user or server | The state of each of the workspace's campaigns here (the Campaigns list) |
| GET | /workspace/:id/run | user or server | The workspace's runs (?flowId=, ?campaignId= narrow it) |
| GET | /workspace/:id/stat | user | Insights: runs per flow, campaign and day |
| GET | /workspace/:id/report?since= | server | The weekly report: real runs by status, busiest flows |
| POST | /workspace/:id/prune | server | { before }: finished runs from before then go, with logs and files |
| GET | /workspace/:id/browser | server | The workspace's browsers here (?state=, ?mode=) |
| GET | /campaign/:id | user or server | The campaign's state (TApp) |
| PATCH | /campaign/:id | server | { change }: only what of its config changed in mooflair (an app not here yet is fetched whole) |
| DELETE | /campaign/:id | server | It's gone: runs stop, worker and folder go (?run=1: it moved engine, its runs and logs here go too) |
| POST | /campaign/:id/start | server | A run (TAppStart); a real Run now restarts its failures from the first |
| POST | /campaign/:id/action | server | A row's action beside the campaign's run: { input, action: { table, rowId, action } } |
| POST | /campaign/:id/stop | server | { reason, isBrowserClose }: every run of it still going stops; answers { count } |
| POST | /campaign/:id/clear | server | Its finished runs and their logs go; answers { id: [...] } |
| GET (WS) | /campaign/:id/socket?token=&after= | user | Every run of the campaign: snapshot, new runs, changes, logs, controls |
| GET | /campaign/:id/run | user or server | The campaign's runs |
| GET | /campaign/:id/log | user or server | The campaign's logs (?runId=, ?level=, ?before=) |
| GET | /campaign/:id/stat | user | The campaign's chart |
| PATCH | /flow/:id | server | { change, isPublished }: only what of its config changed (isPublished: published again, a paused schedule goes on) |
| DELETE | /flow/:id | server | It's gone (?run=1: moved engine, its own runs and logs here go too) |
| POST | /flow/:id/start | server | A run (TAppStart): a trigger's real run, or a test of the draft |
| GET | /flow/:id/run | user or server | The flow's runs (Studio's Runs window) |
| GET | /run/:id | user or server | Run, graph and logs |
| POST | /run/:id/control | user or server | A control without a socket |
| GET (WS) | /run/:id/socket?token= | user | Live changes, logs and controls |
| POST | /run/:id/resume | server | A call to the run's resume URL, relayed by mooflair: { data, upload } (404 not going, 409 not waiting) |
| GET | /run/:id/wait?until=end|wait&timeoutMs= | server | One call that answers once the run ends (or waits from outside) |
| POST | /run/:id/file/:fileId/link | user or server | { isInline } → a 10-minute link { url, expiresAt } |
| GET | /file/:token | token in path | The file's bytes (401 a changed link, 410 expired) |
| GET | /run | server | Admin: everyone's runs (?workspaceId=, ?flowId=, ?campaignId=) |
| GET | /log | server | Admin: every run's logs (?campaignId=) |
| GET | /app | server | Admin: every app here with its state and what it runs now (?kind=) |
| GET | /browser/:id | server | Record + container status |
| PATCH | /browser/:id | server | { proxy }: its proxy changed |
| DELETE | /browser/:id | server | Delete browser, container, network, profile |
| POST | /browser/:id/start · /stop · /restart | server | Lifecycle (one not here yet comes from mooflair first; a start needs room: 429 at the workspace's limit, 503 when the engine is full) |
| POST · DELETE | /browser/:id/live | server or owner | Signed live link · revoke |
| GET | /browser/:id/live/:token/* | token in path | KasmVNC proxy (HTTP + websocket) |
| GET | /browser/:id/stat | server | CPU and memory |
| GET | /browser/:id/event | server | Server-Sent Events |
| GET | /metrics | metrics | Prometheus text (engine_resource: RAM and CPU held and this host's capacity) |
| POST | /usage | server | Report usage to mooflair now and apply its answer (sizes, what to free); 204 once done |
Apps
State
GET /campaign/6ae3…
{
"kind": "campaign",
"id": "6ae3…",
"state": { "window": "2026-11-02#0", "restart": { "count": 1, "at": null }, "failure": { "count": 0 }, "isExhausted": false, "schedule": { "lastAt": null, "pausedAt": null } },
"window": { "isOpen": true, "nextAt": "2026-11-03T02:00:00.000Z" },
"run": { "id": "6ae4…", "status": "RUNNING" }
}
state is the scheduler's (see ); run is its latest real main run.
Config
mooflair sends only what changed ({ change }, fields of TAppConfig: turning a campaign off sends { "isEnabled": false }, picking its browsers { "browserIds": [...] }, publishing a flow { "trigger", "schedule" }); the engine also pulls every app's config at boot and every 5 minutes (GET /api/internal/app), so a change that didn't arrive still does.
The whole config, as GET /api/internal/app answers it:
{ "kind": "campaign", "id": "6ae3…", "workspaceId": "6ad…", "name": "ID affiliate 1", "flowId": "6ae1…", "browserIds": ["6ae5…"], "isEnabled": true, "schedule": { "timezone": "Asia/Jakarta", "everyMs": 0, "window": [{ "day": [1, 2, 3, 4, 5], "from": "09:00", "until": "17:00" }] }, "restart": { "afterMs": 300000, "max": 5 }, "failure": { "limit": 3 } }
A flow's: { "kind": "flow", "id", "workspaceId", "name", "timezone", "trigger": { "id", "field" } | null, "schedule": { "minutes": 60 }, "failure": { "limit": 0 } } (its published Schedule trigger, and the plan's fastest schedule).
Start
POST /campaign/6ae3…/start
Content-Type: application/json
{ "actor": { "userId": "6ad…" }, "input": { "campaign": { "id": "6ae3…" } }, "mode": { "dry": false, "priority": false } }
TAppStart (every field optional): actor (whose datasets; default the app's creator), input, upload (base64 files by key), mode: { dry, priority }, range: { fromId, toId }, resume: { runId }, draft: { graph, canvas } (a test of the draft; graph runs this one instead), action. The engine asks mooflair what the run needs (POST /api/internal/run) and answers 201:
{ "run": { "id": "6ae4…", "status": "QUEUED", "…": "TRun" }, "journal": { "id": "6ae4…" }, "callback": { "link": "https://mooflair.example.com/api/resume/<token>" } }
Errors carry mooflair's reason (422 an option not picked, a plan gate, maintenance; 404 not on this engine; 503 mooflair didn't answer).
Runs
Lists
GET /campaign/6ae3…/run?status=FAILED,CANCELLED&dry=0&before=6ae1…&limit=20
| Query | Meaning |
|---|---|
priority | 1 row actions only, 0 without them. |
status | Comma-separated statuses. |
dry | 1 test runs, 0 real runs. |
before | A run id: the next page (older). |
limit | Default 20, at most 100. |
Answers an array of runs (newest first, without graph).
Detail
GET /run/6ae19596c810b38d560717b2?before=<log id>
{ "run": { "…": "TRun" }, "graph": { "nodes": [], "edges": [] }, "logs": [{ "id": "…", "runId": "…", "nodeId": "collect", "level": "info", "type": "step", "message": "Collected 17 posts", "path": [], "context": null, "createdAt": "…" }] }
Wait
GET /run/6ae4…/wait?until=end&timeoutMs=60000
Answers once the run ended (until=end), or waits for a call or a form from outside (until=wait; its end answers too), or after timeoutMs (25 s by default, 5 minutes at most) with how it is then. A webhook's Respond, a chat, an MCP tool call and a form's next page each make one such call instead of polling.
{ "status": "SUCCEEDED", "error": null, "returned": [{ "price": 120 }], "last": { "item": [{ "dot": "next", "json": { "text": "…" }, "trail": {} }] }, "intervention": null }
Socket
wss://engine.example.com/run/<id>/socket?token=<user JWT>
Only mooflair's origin may open it. Engine → viewer: { "type": "set", "set": { "<path>": value }, "at": … } or { "type": "log", "log": { …, "id": "…" }, "at": … }. See .
Campaign socket
wss://engine.example.com/campaign/<id>/socket?token=<user JWT>&after=<seq>
On every (re)connect: { "type": "snapshot", "seq": 41, "run": TRun[] } (its runs still going and the latest 20), then the buffered messages after after, then live ones with seq and runId. Viewer → engine: { runId, control }.
Statistics
GET /workspace/6ad…/stat?days=30&dry=1
GET /campaign/6ae3…/stat?days=30
days: 1–365 (default 30). Real runs only; dry=1 counts test runs too. Answers { days, flow: [...], campaign: [...], day: [...] }, each with total, succeeded, failed, cancelled, durationMs and ai: { tokens, cost }.
Metrics
Off unless ENGINE_METRICS_TOKEN is set.
engine_browsers{state="running"} 2
engine_browsers_max 20
engine_runs_active{status="RUNNING"} 1
engine_runs_waiting 0
engine_runs_day{status="SUCCEEDED"} 14
engine_ai_day{kind="calls"} 37
engine_ai_day{kind="tokens"} 51234
engine_ai_day{kind="cost_usd"} 0.0184
The engine calling mooflair
The engine calls mooflair's internal API (MOOFLAIR_URL, required) with Authorization: Bearer <ENGINE_SECRET>; mooflair finds the engine by its secret and only answers for what's placed on it. Plain JSON, nothing else.
| Path | When | Body / answer |
|---|---|---|
GET /api/internal/app | Boot, then every 5 minutes (?kind=&id=: one app, first use) | Every campaign on this engine and every flow with a published Schedule trigger, as TAppConfig |
POST /api/internal/run | A run starts | { app: { kind, id }, run: { id }, start, hash } → { definition, run }: the flow ready to run (only when its hash changed, a draft's always) and what only this run gets (options, secrets, browsers, limits, tokens, journal) |
GET /api/internal/browser/:id | A browser's first use here | { workspaceId, proxy } |
POST /api/internal/event | A run ended or waits for a person (run), a notification, the engine turned a campaign off (campaign), a webhook event (webhook) | 204 |
POST /api/internal/usage | Every minute, at boot, and when mooflair asks | Steps, browser and worker time per workspace, and the containers running (lease) → { over, resource, free } |
POST /api/internal/lease | Right before a container starts, and once it stopped | { reserve: { browserId, workspaceId, mode, memoryMb, cpu } } → {} or 429 with the reason; { release: { browserId, workspaceId } } → {} |
Pages' tokens are checked with mooflair's public key from MOOFLAIR_KEY (no call to mooflair).
FAQ
Why does mooflair not send a run payload any more?
The engine knows its apps. It caches each app's flow (graphs, called flows, node code) by a hash mooflair works out, and asks only for what changes per run. Less is sent, and a config change reaches the engine as a PATCH of just the fields that changed.
Why does the page call the engine directly instead of through mooflair?
Live sockets and video don't need to pass through mooflair; the workspace token keeps it scoped to that workspace's runs and browsers. The same token works on every engine.
How long is a workspace token valid?
One hour (src/lib/engine/token). The page gets a fresh one from mooflair; each request checks exp.