Files
app/docs/code/api.md
T
stroblme b7f1d21712
Docs / docs (push) Successful in 3m11s
Playwright Tests / test-playwright (1, 2) (push) Successful in 11m42s
Playwright Tests / test-playwright (2, 2) (push) Successful in 2m22s
pre-commit / pre-commit (push) Successful in 3m12s
Test Backend / test-backend (3.12) (push) Successful in 6m19s
Test Backend / test-backend (3.14) (push) Successful in 5m59s
Compose Smoke Test / test-compose (push) Successful in 37s
Playwright Tests / merge-reports (push) Successful in 1m12s
Docs for key renames: ids and keys in the flow concepts, the rename routes, events and 503s in the API reference, hooks during a rename, sync and run metrics after one; a pinned restore refused for its recorded key names that key in the 409
2026-11-03 08:09:35 +01:00

24 KiB

The HTTP API

The API covers the same operations as the browser. Use it for scripts, integrations, or your own client.

Set the base URL for your instance:

export FLUKSIO=http://127.0.0.1:8000/api/v1

Use the port printed by fluksio serve. For Docker, use http://localhost:8080/api/v1, or https://<your-domain>/api/v1 for a deployment. All paths below are relative to this base. Timestamps in responses are ISO 8601 and always carry their UTC offset.

Authenticating

fluksio serve signs the local CLI in automatically. To reuse its token from a project instance, with jq installed:

export TOKEN=$(jq -r .token .fluksio/client.json)

Adjust the path for --global or --data-dir. For a remote instance, use its login endpoint with the administrator credentials:

export TOKEN=$(curl -s -X POST "$FLUKSIO/login/access-token" \
  --data-urlencode "username=admin@example.com" \
  --data-urlencode "password=<your-password>" | jq -r .access_token)

curl "$FLUKSIO/users/me" -H "Authorization: Bearer $TOKEN"

Session tokens last eight days by default. POST /login/test-token checks a token. Password recovery uses POST /password-recovery/{email} and POST /reset-password/.

Agents over MCP use separate OAuth tokens.

!!! tip "Interactive API reference"

Outside production, open `/docs` or `/redoc` on the API host.
The schema is at `/api/v1/openapi.json`. Access is limited to loopback
unless `API_DOCS_ENABLED=true`; the Docker development stack enables it.
Production disables these endpoints.

Flows

Method Path Action
GET /flows/ list flows and their state; ?name=<key> lists the one with that key
POST /flows/ create a flow from a document; it starts as a draft
GET /flows/{id} read the draft when present, with status and issues
PUT /flows/{id} save a draft of an existing flow; send the current version
POST /flows/{id}/publish publish; send {"version": <current-version>}
POST /flows/{id}/discard-draft discard unpublished edits
POST /flows/{id}/duplicate copy into an unpublished <key>_copy with an id of its own; code nodes need a superuser
DELETE /flows/{id} delete; refused while a run is queued or running
POST /flows/{id}/rename/plan what renaming the key to {"new_name": ...} would change; superuser only
POST /flows/{id}/rename rename the key as planned; superuser only
DELETE /flows/{id}/rename/{request_id} call off a rename still waiting for work to finish
GET /flows/node-types list types and parameter schemas
GET /flows/graph read the graph shown on Home

A flow has an id and a key, its name. The id says which flow it is and never changes. Every path takes the id, never the key. The key is the namespace of the flow's messages (house.temperature) and is unique among flows. A flow created before ids existed has its key as its id.

POST /flows/ takes the same document as a save, without version, and answers 201 with the new flow. The server issues an opaque id such as f_k3q7m2xa. A superuser may pin one instead by sending id, which the Demo and restores do. A pinned id has to be free and cannot be a path of this API like graph. If runs or metrics of a deleted flow are still filed under it, the new flow takes that history back and has to use the key it was recorded under. Another key is refused with 409, and key in the answer names the recorded one. To bring it back under another key, restore it under the recorded key and then rename it. Creating a flow with code nodes needs a superuser, as saving one does. A key that still has values or queued work from an earlier flow is refused with 409 until that is gone.

PUT /flows/{id} only updates. It answers 404 for an id nobody holds, and 409 for a body whose name differs from the stored key. Its body may leave id out.

Deleting a flow keeps its run history, which is shown under the key the flow last had. Delete runs separately. The flow's values and queued work go with it, so its key is free for a new flow, which starts with an empty history.

Answers that lead to a flow or dashboard carry its id next to the key. An endpoint in GET /flows/{id} has owner_id, a neuron in GET /flows/graph lists flow_ids in the order of its flows, a device binding has flow_id and a search entry has id. Open things by these ids, not by the part of a <key>.<node> before the dot.

Renaming a key

A superuser renames a flow's key in two steps. POST /flows/{id}/rename/plan with {"new_name": "warmth"} changes nothing. It lists the flows and dashboards the rename would rewrite, counts the history, live state, queued work and alert channels that would move, and gives warnings and refusals, allowed, the flow's name and version and a fingerprint. POST /flows/{id}/rename then sends the plan's name as expected_name, its version as expected_version, new_name and fingerprint, plus a request_id of your own of up to 64 characters. It answers with the new versions of the rewritten documents. Sending the same request_id again gets the same answer.

The whole engine pauses for a flow rename. The request first waits for running work to finish, however long that takes, and GET /observability/summary shows the rename under rename, with what it waits for in busy. Until it starts changing things, DELETE /flows/{id}/rename/{request_id} or closing the connection calls it off, and the request answers 409 aborted. Meanwhile other changes get 503 with code renaming and Retry-After, and so do the hooks of the flows being rewritten. Reads keep working.

A refusal answers 409 with its code and the fresh plan, and so does plan_changed, when something the rename would rewrite changed since the plan. 409 stale means the flow itself changed. A key that is no valid key answers 422.

POST /dashboards/{id}/rename/plan and POST /dashboards/{id}/rename work the same way for a dashboard. Only its own documents change, and the engine keeps running.

Node source

curl "$FLUKSIO/flows/f_k3q7m2xa/nodes/decide/source" \
  -H "Authorization: Bearer $TOKEN"

curl -X PUT "$FLUKSIO/flows/f_k3q7m2xa/nodes/decide/source" \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"code": "def process(x):\n    return {\"y\": x * 2}\n"}'

Writing code requires a superuser. So does adding, retyping, or repointing Function and Command nodes, changing Command settings, or changing resources.env. Other users can edit wiring, titles, and ordinary settings.

Ordinary source edits stay in the draft until publishing. Shared library source changes affect its users immediately, and the audit trail records them like a publish. POST /flows/{id}/nodes/{node}/share promotes a source to the library; /unshare makes it private again. GET /flows/library lists shared sources and the <key>.<node> of every node using each. DELETE /flows/library/{name} retires one, and answers 409 while nodes still use it. With ?detach=true each of those nodes keeps a copy of the code, in the published flow and the draft, and the shared source is deleted.

Running and controlling

Method Path Action
POST /flows/{id}/start, /flows/{id}/stop activate or stop a live flow
POST /flows/{id}/pause, /flows/{id}/resume hold or release incoming work
POST /flows/{id}/step release one held message
POST /flows/{id}/validate validate the current draft
POST /flows/{id}/run run the current draft once with {"inputs": {...}}
POST /flows/{id}/nodes/{node}/trigger feed a node with {"values": {...}}
POST /flows/{id}/nodes/{node}/cancel stop active node code
POST /flows/{id}/nodes/{node}/acknowledge clear its failure marker

Values, states and node names in the answers are qualified with the key, as in house.temperature and house.decide. Node ids in the paths are the flow's own, unqualified.

A preview run can have real side effects. On a batch flow, /run submits a recorded run; use the run endpoints below for parameters and tracking.

Messages

curl "$FLUKSIO/messages/" -H "Authorization: Bearer $TOKEN"
curl -X POST "$FLUKSIO/messages/house.setpoint" \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d '{"value": 22.5}'
curl "$FLUKSIO/messages/house.temperature/history" \
  -H "Authorization: Bearer $TOKEN"

The catalogue contains messages declared by published flows and their last values. You can publish only to declared messages, just as dashboard controls do.

Runs

Method Path Action
POST /runs/flows/{flow id} queue a run with {"params": {...}, "seed": 7}
POST /runs/flows/{flow id}/sweep queue runs with a shared group
POST /runs/import import up to 200 completed runs
GET /runs query history
GET /runs/overview summarize runs per flow
GET /runs/{id} read parameters, results, nodes, and artifacts
GET /runs/{id}/nodes/{node}/outputs read what one node returned
POST /runs/{id}/cancel, /runs/{id}/retry cancel, or create a retry
DELETE /runs/{id} delete a finished run and its records
DELETE /runs?flow={flow id} delete a flow's runs; refused while any are active. history=true also clears its cascades from the history
GET /runs/{id}/metrics?name=&stride= read metric series
GET /runs/metrics/names list metric names across a selection
GET /runs/series/compare?ids=a,b,c&metric=&x= compare curves by step, time, or another metric
GET /runs/export/metrics, /runs/export/runs stream CSV or JSONL

Submissions return a queued run immediately. Invalid or undeclared parameters return 422 before execution. Optional draft and no_cache flags select unpublished code or skip stage caching.

History filters include flow, status, group, digest, since, before, limit, and offset. Rows arrive newest first; use the last row's created_at as before for stable pagination.

History is kept by flow id, so it stays with a flow whose key changes and stays apart from a later flow that takes a deleted one's key. In a run, a cascade, an engine event and a metrics rollup, flow is the flow's id and flow_name its key, or the key it last had once it is deleted. A flow filter, here and under /observability, takes the id and only the id. Nodes are shown and taken as <key>.<node>, and metric and artifact names keep the key too (train.loss).

A node's outputs come from the stage cache, so only a node that succeeded and may be cached has them, up to 256 kB of JSON. Any other node answers 404.

Exports also accept ids and until, with format=csv or jsonl. The metrics export is a long table; the runs export is one row per run, with the flow's id in flow and its key in flow_name. Use params and metrics to choose columns, including dotted paths into record outputs. An export covers at most the newest 10 000 runs. When a selection matches more, the response carries X-Truncated with that cap and X-Total-Count with the number of runs the selection matched.

Import takes {"runs": [...]}, records completed results without executing code, and deduplicates by external_id. Each run's flow is the flow's id, and its metric names are qualified with the key. See Runs for examples, artifacts, caching, and exports.

Artifacts

curl -X PUT "$FLUKSIO/artifacts?name=model.pt" \
  -H "Authorization: Bearer $TOKEN" --data-binary @model.pt
curl "$FLUKSIO/artifacts/<digest>" \
  -H "Authorization: Bearer $TOKEN" -o model.pt

The upload returns a reference with the content digest. Identical bytes are stored once. Node code uses save_artifact and load_artifact instead. ?volatile=true holds the bytes in the engine's memory for a live frame, where newer frames push them out, instead of storing them. Such a frame is at most MAX_FRAME_BYTES (4 MB), and a larger one is refused with 413. A camera only superusers may see answers 403 to anyone else for the frames its messages hold, the last 8 included (see Who may watch a camera).

A superuser can remove stored bytes with DELETE /artifacts/<digest>. While a run, a current message, a flow input's initial value or a node setting in a published or draft flow still references the digest, the call returns 409. The hourly sweep removes unreferenced bytes on its own, so this is for reclaiming space sooner.

Dashboards and devices

Method Path Action
GET /dashboards/ list dashboards; ?name=<key> lists the one with that key
POST /dashboards/ create an empty draft from {"name": ..., "title": ...}
GET /dashboards/{id}?draft=true read a draft; omit the query for published content
POST /dashboards/from-flow/{flow id} create and publish a batch flow's results dashboard, <key>_results
PUT /dashboards/{id} save a draft of an existing dashboard
POST /dashboards/{id}/publish, /dashboards/{id}/discard publish or discard
POST /dashboards/{id}/duplicate copy into an unpublished <key>_copy with an id of its own
POST /dashboards/{id}/rename/plan, /dashboards/{id}/rename plan or rename the key, see Renaming a key; superuser only
GET /devices/ list devices, with online, last_seen, report and reported_at
POST, GET, PUT, DELETE /devices/{id} create, read, save or delete one; a save sends the version it read and gets 409 when someone saved first. A read adds io and bindings, its ports as the device nodes of published flows wire them
POST /devices/pair start device pairing, optionally with {name, board, firmware}; answers code, secret and the two-character check the device shows beside its code; unauthenticated, five a minute per address (the portal limits its own), the oldest of 50 waiting requests from the same side gives way
GET /devices/pair/{code} poll pairing with its secret; answers device (and panel for older firmware); unauthenticated
GET /devices/pending devices waiting for a code, as they describe themselves, each with its check
GET /devices/pair/{code}/device what is waiting on a code, before it is approved
POST /devices/{id}/pair adopt by {code}, or by {request} from the pending list for a request from your network
POST /devices/{id}/unpair revoke the device credential, one the portal issued included
PUT /devices/{id}/report what the device has, from its own credential only; at most 16 KB, and 64 KB once its named config_schemas are put in place (413 above either), replaces the last report, read leniently (see What a device reports)
GET /devices/{id}/ice STUN and TURN servers for a live view of the device, as {ice_servers: [{urls, username?, credential?}], ttl}, with TURN credentials of the caller's own; 403 for whoever may not watch its camera, and for a device credential

A dashboard has an id and a key the way a flow does, and its paths take the id. A new one gets an opaque id such as d_k3q7m2xa, or one a superuser pins with id. Dashboards have ids of their own, so a flow and a dashboard may share one. PUT only updates, as for a flow.

/panels/pair, /panels/pair/{code} and GET /panels/{id} answer as their /devices/ counterparts, for firmware that has not updated yet.

Secrets, modules, and alerts

Method Path Action
GET /secrets/ list names only
PUT /secrets/{name} set {"value": "..."}
GET /modules/ read installed packages and the manifest
POST /modules/apply replace the manifest with {"requirements": "numpy>=2\n"}
GET, PUT /alerts/config read or update alert configuration
POST /alerts/test/{channel} test a channel
POST, GET, DELETE /alerts/telegram/{channel}/link start, read or cancel connecting a chat to a telegram channel; the start returns the t.me links and never the bot token
GET /alerts/webpush/key the key a browser subscribes against; any account
POST /alerts/webpush/subscriptions, /alerts/webpush/unsubscribe add or remove the calling browser; any account

Secrets, package changes and the alert configuration require a superuser. Any account can subscribe its own browser. Applying a manifest replaces the whole managed environment's package list. Adopted environments are read-only here; install their packages yourself.

Connector state keys can also be listed and cleared by a superuser; see the connector contract.

Observability and workers

Method Path Action
GET /observability/summary health and engine version; always returns 200
GET /observability/timeseries, /observability/flows execution metrics or flow rollups
GET /observability/runs, /observability/events, /observability/dead-letter cascades, events, or abandoned work
POST /observability/flush write buffered metrics and events now; superuser only
GET /utils/health/ deep health check; fails on loop or state-backend problems
GET /workers, /workers/resources attached workers and resource availability
GET, POST /workers/tokens list or mint worker credentials; superuser only
POST /workers/tokens/{jti}/revoke revoke a worker
GET /workers/runtime read node-runner source
WS /workers/attach attach with a worker token
GET, POST /flavors/ list or create resource sizes
PATCH, DELETE /flavors/{name} update or delete a size

Changing flavors requires a superuser. See Remote workers.

The history under /observability is kept by flow id as described under Runs: flow is the id, flow_name the key, and node a <key>.<node>, which is also how timeseries takes its node filter. Dead letters are work the engine gave up on and name the flow and node by key, as the engine runs them.

Live events

Connect to ws://127.0.0.1:8000/api/v1/flows/ws with an Authorization: Bearer <access-token> header.

Browsers first call POST /flows/ws-ticket, then connect with ?ticket=<ticket>. Tickets are single-use and expire after 30 seconds. The socket sends a snapshot followed by node, value, health, and run events. An event about a flow names it by key in flow, the way its nodes and messages are named, and by id in flow_id, which is empty for the engine as a whole. dashboard_changed names the dashboard by id, or nothing when a device's own wiring moved.

A key rename sends rename_waiting with busy while it waits for work to finish, and renamed with kind, id, old and new once it is done. A client then reads again what it shows, at the same ids.

A client asks for the bytes of the media it shows with {"type": "media", "names": [...]}, which replaces what it asked for before, for at most 8 messages, the last 8 it names when it names more. Each frame held in memory then arrives as a binary message before the value naming it: four bytes of header length (big endian), a JSON header {"type": "media", "name", "digest", "media_type", "ts"}, then the bytes. Messages are sent without per-message compression.

A device's socket is bounded to its own messages. Its snapshot carries "demand", the ports it reported as on_demand that someone wants now, and "uplink", "binary" on its own socket and "http" through the portal. When that set changes it is sent {"type": "demand", "ports": [...]}. A port has demand while a socket asks for the media of the message it is wired to, or a node of a running flow reads that message. A socket that falls so far behind that events are dropped for it is sent a fresh snapshot, demand included, and is closed with 1008 if it was unpaired meanwhile.

A person who may not watch a camera (see Who may watch a camera) gets none of its values in the snapshot or after it, and no frames when asking for them. Switching the camera's setting sends that person's open sockets a fresh snapshot.

With a binary uplink the device sends a camera frame as a binary message in the same layout, with the header {"type": "frame", "name": "<message>", "media_type": "image/jpeg", "ts": <device ms>}. A microphone's chunk travels the same way with a media type such as audio/L16;rate=16000. The message has to be one of its in ports wired as image or audio, the media type of that family, and the whole message at most MAX_FRAME_BYTES. The frame is held in memory and published in one step, named after the message and credited to its device node. A frame that does not qualify is dropped, the reason logged once per socket, and the socket stays open. A value of one of its in ports wired as bool, int, float or str goes as the text message {"type": "publish", "name": "<message>", "value": ...}, taken in turn with the frames, so a microphone's listening false arrives after its last chunk. With http it uploads with PUT /artifacts?volatile=true&name=<message> and publishes with POST /messages/<message>. A device that is unpaired or deleted while connected is closed with 1008, at the latest with its next frame.

Live view

A device that reports a live port streams its camera over WebRTC. The video goes peer to peer, or through a TURN relay, and the socket carries only the signalling, as text, so it crosses the portal too. A person's socket sends and hears {"type": "live", "device": "<id>", "session": "<id>", "action": ...}, a device's socket {"type": "signal", "session": "<id>", "action": ...}. A session id is 8 to 64 letters, digits, - or _, chosen by the viewer. In order:

Step Viewer's socket Device's socket
1 sends start hears start with ice_servers minted for the device
2 hears sdp (the offer) with ice_servers minted for the viewer sends sdp with its offer
3 sends sdp with its answer hears sdp
4 sends and hears candidate, the bare candidate:... line hears and sends candidate
5 sends bye, or hears bye or busy with a reason hears bye with a reason, or sends bye or busy

A superuser may watch any device, anyone else a device whose camera is open to members, and start is answered bye with forbidden otherwise. A device speaks only for the sessions it was handed on that socket. The server answers busy itself once a device has 2 sessions, and bye with offline or unsupported when the device is not connected or reports no live port. A session the viewer has not answered within 10 seconds ends with timeout, and either socket closing ends its sessions with closed or offline. A socket that falls 64 signalling messages behind ends the session with overflow instead of losing one. ice_servers follow RTCIceServer: {urls: [...]} for STUN, {urls, username, credential} for TURN, in coturn's REST scheme with a username of <expiry>:<who>, good for an hour. A device that sends its camera's frames on the same socket pauses them while a session negotiates, so its offer and candidates are not queued behind frames.

Errors and client generation

Code Meaning
400 malformed request or refused operation
401 / 403 missing authentication or permission
404 resource not found
409 version conflict, duplicate, or resource still in use
413 artifact upload exceeds the configured limit
422 invalid parameters, ports, or bindings
503 subsystem unavailable, or a key rename in progress, retry after Retry-After

For a version conflict, read current_version, reload, merge your edits, and save again. Other 409 responses describe their own conflict.

make generate-client regenerates the frontend SDK. To generate a client in another language, use the instance's /api/v1/openapi.json schema.