A person looking at a rig's floor can now see its finished work, with each item's answer first in plain words, and find an item by id. Coverage is honest: a bounded or unreadable read says so instead of claiming there's nothing. An artifact reference wraps without changing its text. Six files (+291/-8): the floor page and server, the runtime contract, and tests. Source-only entry; no tag, pin, release or deploy.
14 KiB
The runtime API — HTTP verbs, shapes, failure semantics
contractVersion: 0.1
Base: the runtime's own origin (default http://127.0.0.1:8890). All routes
are GET unless noted. Start every integration by reading GET /api/contract
(shape in contract-meta.md) and checking capabilities.
Proving a verb: use a SINGLE-PROVIDER composition (stable method)
A multi-provider studio masks the failure this rule exists for. With several
providers installed, a verb the compositor routes away still finds something that
answers, and the surface looks healthy. With one provider, a reserved verb that was
not reserved goes to the only provider present, which never declared it — and the
result is a public 404 over a capability /api/contract advertises, while the
runtime answers 200 internally.
So a proof for any verb in this document includes a minimal single-provider studio and a public-versus-internal port check. Measured on a rich dev box, that class is invisible.
Who may answer a verb (stable)
The documented shape binds whoever serves it. In a studio running apps, a provider may implement a verb documented here — that is expected, not a workaround: in a real install the files verbs are served by something that knows about real directories rather than the reference runtime's fixtures. What a provider may not do is answer a documented name with an undocumented shape.
So a surface may rely on the shape without knowing who produced it, which is the only thing that makes these verbs worth documenting. A provider that wants to answer a different question gives it a different name — new names in the same namespace are the provider's own and need no permission. Shadowing a documented name with a different answer is the one move that is out of bounds, because it turns a contract into a coin flip decided by what happens to be installed.
SUBSTITUTING AN IMPLEMENTATION INHERITS ITS WHOLE CONTRACT, INCLUDING THE PARTS YOUR OWN CONSUMER NEVER EXERCISES. This is the half that is easy to miss, because nothing in your app fails while you build it.
Measured: a provider replaced /api/files/read and returned raw for html with
no content. The SDK's own runtime has always listed html among its kinds, and
the shared file viewer renders html into a sandboxed srcdoc — so the viewer
opened, reported kind: "html", and rendered nothing. Every part looked healthy:
the verb answered, the kind was right, the component was correct.
A provider author has no way to discover which documented behaviours they silently dropped, so the rule has to be stated rather than inferred: implement the verb as documented, not as your own surfaces happen to use it. The kinds, the degraded shapes and the error contracts are all part of the name you took.
Three verbs cannot be substituted at all, and it is worth knowing why they are
different in kind rather than more important: GET /api/contract,
GET /api/events and GET /api/factory/state are the runtime describing
itself. Nothing else can answer them, so nothing else may.
Measured, not theoretical. A provider once answered zero-argument
/api/files/tree with a list of bound roots instead of a directory's contents —
a reasonable answer to a different question, since it had several roots and the
reference runtime has one. Every consumer then had to handle both, which is the
cost this rule exists to prevent.
Failure semantics (stable)
- Degraded reads return HTTP 200 with
{ "ok": false, "degraded": "<why>" }— the runtime is healthy, the backing data is not. Render the degradation; do not retry-loop. - Client errors return 4xx with
{ "ok": false, "error": "<what> — <what to do>" }. - Unexpected errors return 500 with
{ "ok": false, "error": "<message>" }. - Unknown
/api/*paths return 404 with an error naming where the verb set is documented. - No contract response ever contains a credential value, token, key path, or fingerprint.
contract.meta
GET /api/contract — stable
Version, capabilities, and the manifest validation report. Documented in
contract-meta.md. Read it first.
observe
GET /api/factory/state — stable
The rig-floor envelope: who is working and what is queued. The shape is owned by this contract (declared here, field-by-field), not by any backing system.
{
"ok": true,
"rig": "<rig name>",
"generatedAt": "<ISO timestamp, stamped fresh per read>",
"seats": [
{ "seat": "<session name>", "pod": "<pod id>", "member": "<member id>",
"state": "working | idle | blocked | stalled | down",
"ageMinutes": 0, "hasWork": false, "pendingWork": 0,
"lifecycle": "<runtime lifecycle string>" }
],
"queue": [
{ "id": "<item id>", "state": "<item state string>",
"destination": "<session>", "source": "<session>",
"tags": ["<tag>"], "updated": "<ISO timestamp>", "title": "<one line>" }
]
}
seats[].stateis a closed set (stable).queue[].stateis an open string set (provisional) — render it, do not switch on it exhaustively.ageMinutesis whole minutes elapsed since the seat's most recent observed activity (any runtime-visible action by that seat), rounded to the nearest minute, ornullwhen no activity timestamp is known. It measures quietness, not uptime: aworkingseat can have a nonzero age.- Degraded shape:
{ "ok": false, "degraded": "<why>", "rig": null, "seats": [], "queue": [] }. done(optional, provisional): finished work, so work handed to a seat stays findable after it closes. Absent means the generator doesn't send finished work;rows: []means none has finished.queuestays open work only.
"done": {
"rows": [
{ "id": "<item id>", "state": "done", "title": "<one line>",
"source": "<session>", "destination": "<session>", "closedAt": "<ISO timestamp>",
"decision": { "reason": "<closure reason> | null", "target": "<closure target> | null" },
"result": "<the note the item was closed with> | null", "resultRead": true,
"resolution": "<string> | null", "artifact": "<evidence reference> | null",
"handedOffTo": "<item id> | null", "handedOffFrom": "<item id> | null" }
],
"coverage": { "looked": 100, "ours": 12, "shown": 10, "limit": 100 }
}
A field the item doesn't carry is null, never invented. resultRead: false means the result couldn't be read (not that
there is none). coverage says how much was looked at; { "unreadable": "<why>" } there means finished work couldn't be read.
The runtime carries done through only in this shape: a done that is sent but isn't in it (rows not a list, a row without a
string id) arrives as { "rows": [], "coverage": { "unreadable": "<why>" } }, never as none, and the open work is kept. Its text is shown as written: this runtime has no private display mode.
stream
GET /api/events — stable (as a signal)
Server-sent events. Signal-only contract: any received message means "state changed — re-fetch via the observe/files verbs." Event payload contents are unspecified in v0.1 and must not be parsed or switched on.
- Event naming (closed set): every change signal is a default, unnamed
messageevent — anEventSource.onmessagehandler receives all of them. The only named event in v0.1 isdegraded. Stable runtimes will not introduce additional named events within 0.x; a new named event is a contract addition and arrives with a version change. - Comment frames (lines starting
:) are keepalives; EventSource ignores them natively. - A
degradednamed event means the signal source is unhealthy: close and fall back to polling (cadence guidance inshell-protocol.md). - Reconnection is EventSource-native. Delivery is at-least-once-ish with no replay guarantee: after any reconnect, re-fetch once rather than assuming you missed nothing.
- AUTOMATING A STUDIO:
networkidleNEVER SETTLES. This stream stays open for the life of the page, so any browser-automation wait that requires an idle network — Playwright'swaitForLoadState("networkidle")and its equivalents — times out on every single page load. It is the most common wait there is, and the symptom reads as a slow or broken studio rather than as a wait that cannot succeed here. Wait for an element, or fordomcontentloaded, instead. The same applies to headless captures that settle on network quiet.
files (read-only)
All paths are pinned inside the runtime's files root; anything outside it is refused with a 4xx naming the pin. There are no write verbs in this contract.
GET /api/files/tree?dir=<abs path> — stable
Without dir: the root listing. With dir (inside the root):
{ "ok": true, "path": "...", "dirs": [{name, path}], "files": [{name, path, size, mtime, kind}] }.
kind ∈ image | video | audio | markdown | html | text | other.
GET /api/files/read?path=<abs path> — stable
Text-like files: { "ok": true, "kind": "...", "content": "<utf8>", "mtime": <ms> }.
Binary/media files: { "ok": true, "kind": "...", "raw": "/api/files/raw?path=...", "mtime": <ms> } —
fetch the raw URL for bytes.
GET /api/files/raw?path=<abs path> — stable
The file's bytes with a best-effort content-type.
The file viewer, and what it depends on (stable)
The runtime serves /file-viewer.js — a component an app imports to render a file
when someone clicks its name (image, video, audio, text, markdown, html). It runs
in the APP's frame and reads through the verbs above.
IT IS ONLY AS REACHABLE AS THE ROOTS BOUND TO THESE VERBS, and that binding is the CONSUMER's composition, not this contract's. The files verbs are root-pinned; a file outside every bound root is refused, so the viewer opens nothing and the failure looks like a broken viewer rather than an unbound root.
Measured on a real install: an app rendered a chain of proof and feedback files living in a mission workspace that no bound root covered. The component was correct and had nothing reachable to open — every entry would have failed. The fix was binding the root, in the studio's own composition.
So an app adopting the viewer declares the root KIND its files live under
(app.json → roots, see app-manifest.md) and the box binds it. If the viewer
fails on every entry, check the roots before checking the viewer.
GET /api/files/search?q=<term> — stable
Case-insensitive filename search under the root:
{ "ok": true, "hits": [{name, path, kind}] }. Bounded result count; queries
under 2 characters return empty hits.
annotations
Marks drawn over a surface by a human or an agent. Full semantics — the record,
the sub-context, and what the shell composes — are in annotations.md; this is
the verb shape.
GET /api/annotations?scope=<key> — stable
{ "ok": true, "scope": "<key>", "records": [ … ] }.
Without scope this answers an empty set, not every scope on the box. A
caller that forgot the parameter would otherwise be handed another surface's marks
and render them over this one.
POST /api/annotations — stable
Body { scope, records }; answers { ok, scope, records }. Replaces the whole
set for that scope. A write naming no scope, or carrying no records array, is
refused with 400 naming which.
Substitutable, like the files verbs. These answer stored marks — data on the box — not the runtime describing itself, so a provider that knows where a real studio keeps annotations should serve them under this shape.
capture
POST /api/capture-target — stable
Body { root, path } — a root KIND and a path relative to it. Answers
{ ok: true, target: { root, path, declaredPath, at } } with path resolved
absolute, or 400 with { ok: false, error }. GET returns the current target or
null; posting null withdraws it.
Roots are KINDS, never paths (app-manifest.md) — a literal directory works
on exactly one machine, and a verb that accepted one would be an unbounded write
surface besides. The pair is resolved against what this box bound and refused at
DECLARATION, so a target that cannot be expressed unsafely cannot be inherited
unsafely by whatever captures.
Containment resolves both sides with realpath: a symlink inside the root that
points outside it is refused. The target directory need not exist yet — resolution
walks to the deepest existing ancestor, so a slice's feedback/ can be declared
before anything has been written there.
An unbound kind is a refusal, never a fallback. Landing captures in an unexpected directory is worse than not capturing.
THIS VERB WRITES NOTHING. It records where a capture would go. The contract
still has no write verbs; whatever performs a capture is a separate concern and
does not exist in the reference runtime — GET /api/contract reports
capture.consumedBy: null to say so rather than leaving the seam looking wired.
runtime-internal — exists in some runtimes; DO NOT build on
Listed so you are warned at the point of temptation. None of these are contract, none are present in the reference runtime's public surface, and each becomes contract only through its own future security/ownership gate:
- files mutation (write / goto / roots overlays)
- shell sidebar-arrangement persistence
- typing text into an agent seat or terminal
- OAuth / credential-connect flows
- any verb that reaches a backing system's API directly
If your surface needs one of these, it needs a contract version that includes it — not a workaround.
Credential flows are the exception to that sentence: they are not waiting on a gate, they are OUT OF SCOPE FOR THIS SDK. Ruled 2026-08-04. The test is audience: using this SDK already implies you can sign in to your own tools, so a subscription-login flow serves a different audience than the one holding this contract, and it belongs to the product that has that audience rather than here.
This is a deliberate property, not a gap: the reference runtime handles no credentials, stores none, and has nowhere to put one. Anything that would give it somewhere — a token store, a callback route, a secret in a manifest — is a change of character rather than an addition, and does not arrive through a version bump.