Ports and the API surface
autodev daemon start is one process with two listeners. One serves people,
one serves machines, and neither answers the other's routes.
autodev daemon start --act-token <token>
| Listener | Default | Audience | Serves |
|---|---|---|---|
| Client surface | 127.0.0.1:7777 | A browser, and the person in front of it | The operator console, /api/*, the /control websocket, and the anonymous pages below |
| Machine API | 127.0.0.1:7788 | The CLI, the fleet, other daemons, your own tools | Versioned JSON under /v1, and nothing else |
Both are loopback by default. Change either with --client-address and
--address.
Why two listeners rather than one
The console must not be reachable on the machine API's port. That is asserted
directly rather than left as a convention: a request for /app.js on 7788 is
a 404, and a test fails if it ever stops being one.
The consequence is the point. Exposing /v1 to a network — to a CI runner, to
another host, to a sidecar — cannot quietly expose an operator console as well,
because the console is not there to expose. The two surfaces have different
audiences, different credentials and different blast radii, so they have
different sockets.
The client surface
Answered without any token
| Route | What it is |
|---|---|
GET /public | The public summary page |
GET /api/public | Aggregates behind that page — counts and rates, never a record |
GET /docs, GET /docs/<page> | This documentation site, compiled into the binary |
These are the whole anonymous surface. /api/public projects aggregates and
proxies no record: no story id, no title, no path, no commit, no branch, no
model name, no worker id, no transcript byte, no repository root. The field list
is a whitelist, and a test fails when the shape changes without that decision
being made again.
/docs is the documentation site built into the executable, served from a fixed
map of URL to embedded bytes. There is no directory walk and no path resolution,
so no request can turn it into a file server — which is what lets it sit below
the token beside /public. It also means the documentation a daemon serves is
always the documentation that shipped with it.
Answered for a reader
Everything else under /api/ is readable, including the board projection, the
document manifest, the file tree, change sets and workflows. Reading is free on
loopback, which is a property of the loopback deployment and not of the design.
| Route | What it is |
|---|---|
GET /api/session | Who this client is, as far as the endpoint knows |
GET /api/workspaces | Every registered project, and any served root that is not registered — named as unregistered rather than omitted |
GET /api/documents, /api/document?path= | The curated document manifest, and one document |
GET /api/files, /api/file?path= | Repository-visible files, and one file |
GET /api/diff?selector= | One change set, from the canonical resolver |
GET /api/events?after=&before=&limit= | The event log, cursor-paged: newest window by default, after pages forward, before pages backward, ceiling 1000 |
GET /api/metrics?hours= | Every operational measure in one answer — funnel, judge, latency, ticks, gate, cost, board — each with its denominator, null where unmeasured |
GET /api/workflows, POST /api/workflow/check | Workflow definitions, and a dry-run check |
GET /api/canvas | The shared canvas |
GET /api/connectors, /api/operations/{id} | The connector catalogue and one operation |
GET /control (websocket) | The live projection, with ?workspace= naming the project |
Prefix any of these with a registered project to scope it to that project rather
than the default one: GET /api/workspaces/{project}/documents. An un-prefixed
route means the project the daemon was started in, so a single-project
operator's URLs are unchanged.
GET /api/openapi.json serves the machine API's own description, generated from the Rust request
and response types at request time — so it describes the binary answering it, never a checked-in
file that drifted.
Answered only for an actor
Mutating routes — parking and unparking work, retiring a wave, answering a
request, depositing a credential, registering a project — need the act role.
GET /api/session states whether this client holds it and what granted it, so a
console never has to infer its own authority by trying something and reading the
refusal.
With --single-user every client on the loopback endpoint holds act.
Otherwise it is granted by the token the daemon was started with:
http://127.0.0.1:7777/?token=<the token you passed to --act-token>
One authority model covers the whole surface: the token the websocket compares is the token a mutating HTTP route compares. Every mutating call is written to the audit log with its principal, its verb and its path — never its payload.
This is named as one. --act-token is a single shared secret with no identity,
no expiry and no revocation, and it appears in a query string. It is adequate
for a loopback console and nothing else. Real sign-in replaces it, and none of
the shapes above change when it does — which is why the organization is resolved
from the session rather than from a URL.
The machine API
Every route is /v1-prefixed and workspace-addressed. Authentication is the
daemon's bearer token:
autodev daemon status # prints the address and the token
curl -H "Authorization: Bearer $TOKEN" \
-H "x-autodev-client-version: 0.1.0" \
-H "x-autodev-principal: me" \
http://127.0.0.1:7788/v1/workspaces
Three headers, and each absence is a refusal rather than a guess:
| Header | Why it is required |
|---|---|
Authorization: Bearer | There is no ambient authority. A wrong token is unauthorized, not a parse failure |
x-autodev-client-version | A client that never says what it is cannot be told it is too old. A mismatch is version-skew (426) |
x-autodev-principal | Required for mutating calls, so the audit trail has somebody in it |
GET /v1/health is the one route that answers anonymously: a client has to be
able to ask what it reached before it can authenticate to it.
The published routes
The route table is generated from the Rust request and response types, so it cannot describe a shape the daemon does not serve:
autodev daemon openapi # print it
autodev daemon openapi --write # regenerate docs/api/openapi.json
| Group | Routes |
|---|---|
| Daemon | GET /v1/health |
| Workspaces | GET/POST /v1/workspaces, GET /v1/workspaces/{ws} |
| Planning | GET /v1/workspaces/{ws}/board |
| Tree | GET /v1/workspaces/{ws}/documents, /document, /files, /file |
| Change sets | GET /v1/workspaces/{ws}/diff |
| Fleet | GET/PUT /v1/workspaces/{ws}/waves[/{id}], workers[/{id}] |
| Delivery | GET/POST .../handoffs, .../deliveries, GET .../deliveries/for, .../delivered-write-sets |
| Record | GET/POST .../events, .../transcripts and its five sub-routes |
| Measures | GET /v1/workspaces/{ws}/metrics — the same block /api/metrics serves, for platform consumers |
| Queue | GET/POST .../tasks, POST .../tasks/{id}/assignee, .../tasks/{id}/state |
| Conversation | GET .../conversations/{profile}, POST .../messages, POST .../answers |
| Exclusion | GET/POST /v1/workspaces/{ws}/locks/write, DELETE .../locks/write/{token} |
The client holds no path
A /v1 caller names a workspace, never a directory. The daemon resolves it
to bytes through the registry, and refuses an unregistered one by name:
{
"refusal": "workspace-not-registered",
"detail": "this daemon serves no workspace `ghost`",
"workspace": "ghost",
"remedy": "run `autodev workspace register <repository-root>`"
}
That is what makes the tree routes worth having. documents, files and diff
read the registered root under the same allow-list, the same byte ceiling and
the same symlink policy the local reader always applied — and return the same
refusal codes, which is asserted against the reader itself rather than restated:
| Refusal | Status | When |
|---|---|---|
document-path-traversal | 403 | A path with an absolute, parent or current-directory component |
document-not-allowed | 403 | Outside the curated classes, or not repository-visible |
document-symlink-escape | 403 | Resolves outside the repository root |
document-symlink-target-not-allowed | 403 | Resolves to a non-curated path inside it |
document-too-large | 413 | Above the ceiling, which is reported with the refusal |
document-not-text | 422 | Not valid UTF-8, or contains NUL bytes |
document-missing | 404 | Gone since the manifest was read |
unsafe-ref | 400 | A change-set selector naming something like HEAD, which means something different on every tree |
A client may ask to be answered under a smaller byte ceiling with
?max_bytes=, and never a larger one. Every answer carries the bound it was
read under, so a small file and a truncated one are distinguishable rather than
inferred.
What replaced what
autodev serve used to be a second process holding a --root, reading the
planning layer, the file tree and diffs off its own filesystem while reaching
the store through the daemon. Two sources that could disagree, with the
tie-broken by whichever path an operator typed.
The daemon now serves both, and there is one answer to "which project" again.
serve still starts and still works for one release, so nothing breaks on
upgrade; it is no longer the supported way to run the console.
The workspace chooser names the project in view and scopes client routes to it. Several registered workspaces therefore share one daemon and one global runtime store without relying on the daemon's working directory as project identity.
Reaching it
autodev daemon start --act-token dev
# the console
open http://127.0.0.1:7777/?token=dev
# the public page, and these docs — no token
open http://127.0.0.1:7777/public
open http://127.0.0.1:7777/docs
# the machine API — health needs no token
curl -sS http://127.0.0.1:7788/v1/health
# everything else does; `daemon status` prints it
autodev daemon status --output json
See Central daemon and workspaces for registering repositories, and Current limitations for what is still open.