Reference
HTTP API
The daemon's API is everything it does: the command line and the console are clients of it, and so can any program be.
$ curl -H "Authorization: Bearer $(cat ~/.qoqi/token)" http://127.0.0.1:7070/api/instances
Conventions
- Address.
qoqi serve --listen,127.0.0.1:7070by default. The daemon answers only to its own host,localhost,127.0.0.1,::1and the names of--hosts; anotherHostis403. - Authentication. Every request but
POST /api/attemptsand/api/auth/*carriesAuthorization: Bearer <token>, or the session cookie of a person signed in to the console. The token is in$QOQI_PATH/token(~/.qoqi/token), made when the daemon first starts, readable by its owner only. Wrong or missing:401. A request of a session that changes something carriesX-Qoqi-Console: 1too, which a page of another site cannot send. - Bodies are JSON, both ways.
- Errors are
{"error": "<code>", "message": "<sentence>"}, with the status of the table.
Values
Fields, messages, step arguments and answers are written in the boundary form: plain JSON, read against the type the program declares.
| Qoqi | JSON |
|---|---|
Int |
A whole number, from −140 737 488 355 328 to 140 737 488 355 327 |
Float |
A number; "NaN", "+Inf", "-Inf" as texts |
String, Bool |
A string, true or false |
T? |
The value, or null. A missing optional field is null; a missing field that is not optional is refused |
| A struct | An object of its fields; an unknown field is refused |
| An enum value without values | Its name: "Approved" |
| An enum value with one value | {"Refused": "too much"} |
| An enum value with several | By position, {"Refused": ["ada", "too much"]}, or by name, {"Refused": {"who": "ada", "why": "too much"}} |
Result<T, E> |
{"Ok": value}, {"Err": error} |
Duration |
A text with units: "90s", "1h30m", "2 days", "250ms"; units ms, s, m/min, h, d, w |
| An array | An array |
| A dictionary | An object |
Instances
GET /api/instances
The instances, sorted by name. ?flow=Expense, ?status=waiting.
[
{
"id": "expense:1",
"flow": "Expense",
"status": "waiting",
"birth": "e60eef3d",
"wakeAt": "2026-10-19T22:30:39.065Z",
"stage": "asking",
"pass": 1,
"version": "sha256:43381189…"
}
]
Besides id, flow, status and birth, a field is there when it has a value: wakeAt, fields, result, stopped (why), steps, undos, cancel, parent, children, stage, pass, version, ended.
POST /api/instances
Starts one: {"flow": "Expense", "id": "expense:1", "fields": {"id": "1", "author": "lin"}}. Without id, one is made: expense:3f9c01ab.
| Status | |
|---|---|
201 |
Started; the instance |
200 |
The name was taken by an instance of this flow; that instance |
400 fields |
The fields do not fit the flow |
404 flow |
No such flow is served |
409 taken |
The name is an instance of another flow |
?dry=1 checks the fields and starts nothing.
GET /api/instances/{id}
The instance, and for people: tasks (where each is, its calls and their lines), kept (what its steps returned, by key), tries, retries, output (its last 200 lines). 404 if there is none.
GET /api/instances/{id}/result
What its run returned. ?wait=30s waits that long at most (a minute at most).
| Status | |
|---|---|
200 |
The result |
202 |
Not ended yet; the instance |
409 stopped |
It is stopped, and why |
409 cancelled |
It was cancelled, and why |
POST /api/instances/{id}/inboxes/{inbox}
A message: {"message": {"approved": true, "why": ""}, "key": "mail-1"}. The message is read against the inbox's type; a key sent before is one message.
| Status | |
|---|---|
200 |
{"sent": "delivered"}, or {"sent": "already"} for a key sent before |
400 message |
The message does not fit the inbox |
404 inbox |
The flow has no such inbox |
409 ended |
The instance has ended |
429 full |
The inbox holds all it can: send it again later |
POST /api/instances/{id}/cancel
{"reason": "withdrawn"}, optional. 202 and the instance; 409 ended if it has ended.
POST /api/instances/{id}/resume
Takes a stopped instance up from its last stage. {"dry": true} answers 200 with what it would do: the results it would take as kept (took), the errors it would forget and run again (forget), what it would do next (next). {} does it: 202. 409 resume if it cannot be.
POST /api/instances/{id}/answer
Says what a step of a stopped instance did, for a step its application could not tell of (GET /api/instances/{id} lists the steps that left unanswered, unanswered): {"key": "<step key>", "value": {"Ok": "ch-1"}}, or {"key": "…", "nothing": true} when it did nothing, so that it is sent again. 200 {"answered": true}. Then resume.
POST /api/instances/{id}/end
Ends a stopped instance as it is, failed, without running its undos: {"reason": "refunded by hand"}. 202; 409 end if it is not stopped.
Programs and deploys
GET /api/flows
The flows served, with how many instances are in each status: [{"name": "Expense", "instances": {"waiting": 3, "done": 12}}].
POST /api/deploy
What qoqi deploy sends: {"programs": [...], "dry": false}, each program compiled by the command. The answer is {"reports": [{"program", "version", "before", "new", "same", "changes", "moving", "lock"}]}. 409 refused when a version changes the shape of a stage that an alive instance may be at, with no migration.
POST /api/undeploy
{"program": "approvals"}. 200 {"removed": "approvals"}; 409 refused while one of its instances is alive.
POST /api/deployments/{seq}/redeploy
The version of a deploy, deployed again as a new one, checked as any is: a rollback. ?dry=1 says what it would do.
GET /api/deployments, GET /api/deployments/{seq}, GET /api/deployments/{seq}/diff
The deploys, the last first (?program=); one, with its sources; what it changed of the source, from the one before or ?from=seq, as hunks.
GET /api/program, GET /api/source?file=
The types of the flows served, their fields, inboxes and steps (what the console builds its forms from); the lines of a file of a program.
Events
GET /api/events
Server-sent events: one data: {...} line per event, of kind instance (with the instance), step, output or deploy. A comment line every 15 seconds keeps the connection alive.
GET /api/output
What the instances printed, the last 500 lines: [{"at", "id", "text"}]. ?instance= for one.
The console
These are what the console is made of; a program has little use for them.
GET /api/auth, POST /api/auth/login, POST /api/auth/logout
Who is there: {"accounts", "me", "prefs"}. login takes {"name", "password"} and sets the session cookie, HttpOnly, SameSite=Strict, for 14 days. Five wrong passwords for a name in a quarter of an hour, and it is refused (429) until it passes. The console starts with admin, password admin.
A person whose password was given them (admin's first, or one set by another) is "renew": true: until they choose their own with POST /api/users/{name}/password, every other request is 403 renew.
GET /api/users, POST /api/users, DELETE /api/users/{name}, POST /api/users/{name}/password, PUT /api/me/prefs
The people who sign in. A password has 10 characters at least; a user's own needs "current" besides, and is another than it. A new password ends the user's sessions; one given by another is to be changed at the next sign in. The last user, and oneself, are not taken away.
GET /api/workspace, GET|DELETE /api/workspace/{program}, PUT|DELETE /api/workspace/{program}/files/{file}
The editor's programs: those deployed, and the drafts written in the console, kept in the store until deployed. A program opens as its draft, or as the files of its last version. Its entry is {program}.qi; its other files are modules, and qoqi.json the queues of its steps.
POST /api/workspace/{program}/check, POST /api/workspace/{program}/deploy, POST /api/format
check compiles a draft, with {"files": {...}} over what is kept: {"ok", "flows", "problems": [{"severity", "message", "file", "line", "column", ...}]}. deploy does the same, then deploys it, or says what it would do with ?dry=1: 422 when it does not compile, 409 when the daemon refuses it, with why. Deployed, the draft is done with. format writes {"text"} as qoqi fmt does.
POST /api/workspace/{program}/test
The tests of a draft, name_test.qi files, run by the daemon as qoqi test runs them, with {"files": {...}} over what is kept, "file" for one file, "filter" for the tests whose names hold it: {"ok", "passed", "failed", "files": [{"file", "tests": [{"name", "ok", "message", "file", "line"}], "problems", "failure", "output", "ms"}]}. One run at a time, a minute at most; nothing is deployed, nothing written in the store.
POST /api/workspace/{program}/try, GET|DELETE /api/sandboxes/{id}, POST /api/sandboxes/{id}/{advance|send|cancel|replace}
A flow of a draft tried: an instance run in memory with a clock that moves only when asked, as flow.test does. try takes {"flow", "fields", "replace": {"step": value}, "files"}; then advance {"ms"}, send {"inbox", "message"}, cancel {"reason"}, replace {"step", "value"} (null for the step to run as it is again). Each answers the trial: the instance as GET /api/instances/{id} shows one, its clock, how many times each step ran, what happened. A step not answered for runs for real. A trial is its user's; it is forgotten after half an hour unused.
GET /api/stats?days=365&tz=60&flow=
What happened, day by day, cut at midnight tz minutes from UTC: instances started, done, failed and stopped and steps run each day; each flow, with how long its instances took (median, 95th); the steps that run the most; the starts by day of the week and hour. Kept from the time the daemon has the console's tables.
Steps carried out by applications
POST /api/attempts
An application's answer to a step it accepted to answer later: {"key", "attempt", "token", ...answer}, the answer in one of the shapes of the protocol. It carries no bearer token: the attempt's own token, sent with the step, is its authorization.
| Status | |
|---|---|
200 |
{"kept": true}, or {"already": true} if an answer was there |
403 token |
Not the attempt's token |
404 attempt |
No such attempt |
410 late |
The attempt was given up, past its within |
422 value |
The value does not fit the step's type; the instance stops |
{"working": true} keeps nothing: it asks whether the attempt is still alive (410 if not).