Skip to main content

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>
ListenerDefaultAudienceServes
Client surface127.0.0.1:7777A browser, and the person in front of itThe operator console, /api/*, the /control websocket, and the anonymous pages below
Machine API127.0.0.1:7788The CLI, the fleet, other daemons, your own toolsVersioned 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​

RouteWhat it is
GET /publicThe public summary page
GET /api/publicAggregates 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.

RouteWhat it is
GET /api/sessionWho this client is, as far as the endpoint knows
GET /api/workspacesEvery 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/checkWorkflow definitions, and a dry-run check
GET /api/canvasThe 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.

A token in a URL is a stopgap

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:

HeaderWhy it is required
Authorization: BearerThere is no ambient authority. A wrong token is unauthorized, not a parse failure
x-autodev-client-versionA client that never says what it is cannot be told it is too old. A mismatch is version-skew (426)
x-autodev-principalRequired 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
GroupRoutes
DaemonGET /v1/health
WorkspacesGET/POST /v1/workspaces, GET /v1/workspaces/{ws}
PlanningGET /v1/workspaces/{ws}/board
TreeGET /v1/workspaces/{ws}/documents, /document, /files, /file
Change setsGET /v1/workspaces/{ws}/diff
FleetGET/PUT /v1/workspaces/{ws}/waves[/{id}], workers[/{id}]
DeliveryGET/POST .../handoffs, .../deliveries, GET .../deliveries/for, .../delivered-write-sets
RecordGET/POST .../events, .../transcripts and its five sub-routes
MeasuresGET /v1/workspaces/{ws}/metrics — the same block /api/metrics serves, for platform consumers
QueueGET/POST .../tasks, POST .../tasks/{id}/assignee, .../tasks/{id}/state
ConversationGET .../conversations/{profile}, POST .../messages, POST .../answers
ExclusionGET/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:

RefusalStatusWhen
document-path-traversal403A path with an absolute, parent or current-directory component
document-not-allowed403Outside the curated classes, or not repository-visible
document-symlink-escape403Resolves outside the repository root
document-symlink-target-not-allowed403Resolves to a non-curated path inside it
document-too-large413Above the ceiling, which is reported with the refusal
document-not-text422Not valid UTF-8, or contains NUL bytes
document-missing404Gone since the manifest was read
unsafe-ref400A 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.