This document records the actual HTTP/WebSocket contract between
openai4s/server/gateway.py (backend) and the workbench frontend
(frontend/ source, served from openai4s/server/webui/dist/;
openai4s/server/webui/app.js is the OPENAI4S_WEBUI=legacy hatch),
including known warts and gaps. It is descriptive, not
aspirational: every claim below maps to the Gateway/frontend or to a focused
service they compose (notably the execution coordinator, session-domain,
workbench-state, and permission services). If you change that public surface,
update this document.
Scope note: this covers the gateway started by openai4s serve /
./start.sh, which is the only HTTP surface the package serves. A second,
minimal server (openai4s/server/daemon.py, POST /run) was removed rather
than documented: nothing imported it and it had none of the gateway's Host,
Origin, token or header defences.
Stage 2 implements the versioned storage/read/configuration surface:
GET/PATCH /api/v1/frames/{fid}/auto-mode and
GET /api/v1/frames/{fid}/auto-audits?subject_kind=&before=&limit=. There is no
unversioned or alternate alias and no HTTP transition endpoint. In particular,
these routes cannot invoke a Reviewer, Repair Agent, or Permission Guardian,
resolve a permission, or resume imported execution.
GET returns {schema_version,feature_enabled,writable,disabled_reason,
root_frame_id,branch_id,selection,deployment,budgets,run,last_event_id,
last_event_ordinal}. budgets are deployment hard ceilings and are read-only
in Stage 2. PATCH accepts a CAS revision plus only preset,
result_review_mode, and approvals_reviewer; all three null values clear the
frame override. The Stage 2 flag defaults off: GET then says
feature_enabled:false/writable:false, while PATCH returns
409 auto_mode_storage_disabled. A quarantined import always projects
off/user, is non-writable, and returns 423 to PATCH.
The durable and post-commit WebSocket vocabulary is exactly
auto_run_started, candidate_ready, auto_audit_started,
auto_audit_completed, repair_started, repair_completed, and
auto_run_terminal. SQLite, not the socket, is truth. A reconnect or lost hint
uses GET and the event cursor; it never retries an already committed side
effect. Checkpoint/fork/revert reads apply the branch's auto_event_cursor, so
abandoned tails remain in physical audit storage but cannot mutate or appear in
the active logical projection.
auto_audit_started and auto_audit_completed are the sole wire and storage
event names for both audit kinds. Their required subject_kind is
result_review or permission_review; phrases such as “review started” and
“Guardian completed” are domain descriptions, not additional event types or
aliases. One durable event id names one committed transition, so an adapter
must never duplicate it under a second name. The existing permission_resolved
event remains canonical; Stage 7 attaches resolution_actor and audit_id to
that event after the corresponding durable transaction rather than inventing a
second permission-resolution event.
The audit envelope also requires an orthogonal
subject_entity_kind: result_review pairs only with
candidate_evidence_snapshot, and permission_review pairs only with
approval_action. Entity values must not be placed in subject_kind and do
not create event aliases. A started/completed pair shares one audit_id and
one audit_request_digest, which binds only the immutable request and subject.
auto_audit_completed additionally carries the canonical assessment and a
separate assessment_digest binding the request digest, subject fields,
attempt, verdict/decision, findings, risk, authorization, outcome, rationale,
failure, durability, and retry state. Request and assessment digests are not
interchangeable; the server must fail closed on reuse, substitution, or an
assessment hash mismatch.
The existing per-frame auto_review preference still starts the legacy,
single-call evidence Reviewer after the answer has already been finalized. Its
ordinary step/step_update records do not promote or veto frame_update, and
reopen/share/export must not reinterpret such a step as a new Auto Mode
terminal fact. Legacy true migrates only to result review_only; it cannot
enable repair or permission review.
OPENAI4S_STAGE3_SCIENTIFIC_REVIEW_SHADOW=1 adds a post-delivery shadow
Scientific Reviewer V2 step (kind=review, input.mode=shadow). It does not
change frame_update, does not emit a Verified badge, and does not add a new
HTTP route. Plan turns are included. When Stage 2 storage is also enabled, the
shadow review is persisted as a result_review audit on the current Auto Run
and remains non-terminal.
OPENAI4S_STAGE4_REVIEW_COMPLETION_GATE=1 runs that review before promotion
when result_review_mode is not off, and the turn is ordered candidate →
frozen evidence → review → promotion.
While the gate is armed the composed final answer is streamed as a text_chunk
carrying provisional: true, review_status: "candidate", turn_id, and
execution_id; the marker applies to every prose chunk, not only the completion
suffix. At the turn boundary the exact turn-wide candidate is committed once as
a canonical assistant message with the same identities and Candidate metadata,
before the Reviewer runs. A Stage 1 Artifact manifest is likewise committed but
unpublished before its exact-version link is emitted. Live order is the early
Candidate marker and provisional stream, durable canonical candidate, review
events, one atomic promotion/terminal commit, candidate_resolved, then one
terminal frame_update with review_status. A verified final therefore always
arrives after a durable passing review and successful delivery, never before it.
candidate_resolved is the promotion applied to what was actually delivered. It
carries the exact message_id, turn_id, execution_id, review_status,
user_truth, durable, delivered, replaced, answer_repaired, an optional
delivery_id, and the canonical text after durable promotion. replaced
means the provisional live rendering must be reconciled to those exact reviewed
bytes; answer_repaired separately says Stage 5 changed the candidate's
substance. The frontend upserts the identified stored/live row, so provider
whitespace and incremental chunk separators cannot make live display differ
from REST reopen, and the old claim cannot remain beside its correction.
GET /frames/{id}/messages may include an optional review_status object
(status, unverified, user_truth) reconstructed from message metadata,
plus turn_id and execution_id for a gated row. Refreshing cannot promote a
candidate to Verified without that durable stamp. A daemon lost mid-review
leaves both the canonical row marked Candidate and the frozen/open review audit;
it does not lose the answer and does not manufacture a verdict.
Verified is stamped only on the exact bytes the passing review read. A mismatch, a repair the caller could not deliver, or a delivery that failed after the review all resolve to a non-verified terminal rather than a guess. Every rollout stage consumes only its own flag. The full truth and recovery rules are frozen in the Auto Mode product contract.
Any blocked_by_guardian projection requires a durable Guardian assessment
of a deterministic ask. Sandbox, egress, secret/credential, biosecurity,
cost, deterministic hard-deny, action-digest mismatch, and permission-audit
persistence controls remain prior to Guardian; the API/UI must not claim
Guardian made those decisions. Projection follows the committed reason:
policy_requires_explicit_setup, budget_exhausted,
safe_rollback_unavailable, outcome_unknown, loop_detected, or the
hard/integrity safety_boundary, with the user truth frozen in the Auto Mode
contract.
http.server.BaseHTTPRequestHandler, HTTP/1.1
(protocol_version = "HTTP/1.1"), hand-rolled WebSocket upgrade on
/api/v1/ws. Default bind 127.0.0.1:8760./api/v1/* — contract v1. The handler strips the
/api/v1 prefix (_API_ROOT) and matches the remainder (sub) with a long
if/re.fullmatch chain in Handler._api — there is no route table or
OpenAPI spec yet./api/ that is not /api/v1/ returns 404 with a JSON body naming
api_root. That is deliberate: falling through to the SPA shell would answer
200 text/html to an API call, which a client reads as success and then
fails to parse — a worse failure than a clear one. The un-versioned /api/*
surface was removed outright rather than aliased, because it had no external
consumers at the time of the cut.API constant in app.js,
so a future version bump is one line there plus _API_ROOT in the gateway.{"error": <message>, "code": <stable>, "status":
<int>, "request_id": <id|null>}, plus any route-specific diagnostic fields.
code is the machine-readable contract; error remains the human message and
is unchanged, so the enrichment is additive. Match on code, never on prose —
the message wording is not an interface and will be improved.
The enrichment never overwrites a field the route itself set, so on the few
routes that return a domain result under an error code — POST
/frames/<id>/recovery/actions/<id> answers a failed action with its whole
result and 409 — status stays that route's own value. Read the HTTP
status line, or code, when you need the transport status specifically;
those are always present, whereas a clobbered domain field has no second
copy.
Status is too coarse to branch on alone: four distinct 400s
(malformed_json, invalid_body_type, invalid_cursor, invalid_limit)
need telling apart, and a client retrying invalid_cursor the way it retries
a transient failure would loop on a request that can never succeed.
request_id matches the X-Request-Id response header and the correlation id
in the structured log line, so one id ties a user report to a server event.
A background job that fails carries the same field on its success-path
body (200 {"status":"failed", …} from a waited turn, plan or cell), holding
the id of the request that started it. It used to be absent there, and worse,
the log lines from inside those job threads carried an empty id: a new thread
starts with an empty contextvars context, so the id was lost at exactly the
boundary where the slow, failure-prone work begins. The field is omitted,
never null, when a job was built outside any request — a daemon-lifetime
sweep or a recovery pass — because null would read as "this request had no
id" rather than "there was no request".{data: …} envelope. Considered and
declined: it would churn every route and every consumer to relocate
information that is already unambiguous, and a half-finished reshape presents
as a silently broken screen rather than a failing test. What the contract
needs from the success side is a documented, stable shape per route, which the
route/event inventory test enforces.seq per root frame. A client resumes
with {"type":"view_session","root_frame_id":…,"since_seq":N,"epoch":E} and
receives only events after N; replay_begin reports from_seq/to_seq,
this daemon run's epoch, and
gap: true when the capped buffer no longer reaches back to N+1, so a
client that was away too long can refetch state instead of resuming into a
hole it cannot detect. since_seq absent or 0 replays the whole buffer.gap: true
in that case, detecting it either from a mismatched epoch or, for a client
that sends none, from its own counter sitting below the presented cursor.
The client stores the epoch, drops every cursor when it changes, and
refetches the session on gap: true. A cursor the server cannot place
replays nothing — the client is about to refetch, so replaying the buffer
from the start would render events that are immediately discarded, and a
fabricated cursor must never wrap around into a full replay.
The counter does not reset between turns — a per-turn counter would make a
stale cursor look already-satisfied and skip the new turn's first events.
Only broadcast events are sequenced; point-to-point snapshots delivered on
subscribe (execution_queue, pending approval cards) and the replay control
frames deliberately carry no seq./, /index.html, and any unknown
non-API GET serve the SPA shell (webui/dist/index.html by default;
webui/index.html when OPENAI4S_WEBUI=legacy) so deep links such as
/projects/{pid}/frames/{fid} work. /static/* is the ordinary static
tree. Unknown non-GET, non-API paths return 404 {"error": "not found"}.application/json; charset=utf-8 with
Cache-Control: no-cache and an explicit Content-Length.Handler._body() accepts an
empty body, but an unparsable one is rejected with 400 malformed_json, and
a body that parses to something other than an object with
400 invalid_body_type. Neither is silently coerced to {}.parse_qs (every value is a list;
handlers read q.get("x", [default])[0]).POST/PUT/PATCH/DELETE)
to /api/v1/* whose Origin header is present and whose netloc differs from
the Host header is rejected with 403 {"error": "cross-origin request
refused"}. Requests without an Origin header (curl, same-origin fetches)
pass./health, /api/v1/auth/status, and the separately authorized sandbox-grant
byte route require a credential, in either spelling:
the os_token cookie, Authorization: Bearer <token> (what a generic client
or curl -H reaches for), or X-OpenAI4S-Token (for when something upstream
already owns Authorization). Neither header is preferred; the scheme is
compared caselessly per RFC 7235 and the value in constant time.?token= bootstraps the root page only. A GET for / or
/index.html responds 303 with the cookie set, redirecting to the same
page with only the token stripped (any other query parameters survive).
Everywhere else it is refused — deep links included. They used to bootstrap,
because the rule was "any path that is not /api/v1/* or /static/*", and
/preview/<id> is neither: a link carrying a token there set the cookie and
then served the artifact bytes. A URL carrying a credential is a shareable
credential: pasted into chat, logged by proxies, kept in history, leaked by
Referer. On the root page the link buys an empty SPA shell; on a data path
the response is the payload.token query parameter on a mutating request is refused outright with
401, even when the request also carries a valid cookie or header. Ignoring
it meant the leaked URL worked, so the caller never discovered they were
shipping a secret in a URL. Send Authorization: Bearer <token> or
X-OpenAI4S-Token instead.access-token, owner-only) and
survives restarts; it used to be per-boot, which invalidated every cookie
already issued. The CLI reads the same file, or OPENAI4S_TOKEN when the
daemon runs under another account.OPENAI4S_REQUIRE_TOKEN=0 disabled it on
loopback for exactly one minor release (decision D1, v0.2.x); from 0.3.0 the
variable is ignored on every bind, as it always was off loopback.GET /api/v1/auth/status is reachable unauthenticated so a client can
discover it needs a credential, and answers
{authenticated, auth_mode: "token", token_header} — a mode string
only, never any part of the token. It previously reported "none"
unconditionally, so a daemon running with the gate on told every caller there
was no gate.{"error", "code", "status", "request_id"} plus route-specific diagnostic
fields. The unenriched {"error": "<message>"} shape this section used to
describe has not been the wire format since the envelope landed; the two
sections contradicted each other, and the frozen artifacts agreed with the
wrong one because the contract capture observed bodies before enrichment ran.
The sources are unchanged: raised GatewayError(code, message); any
unhandled exception → 500; the _api catch-all → 404 carrying path
and method.api() helper reads j.error || j.detail, so the Gateway's
error text is shown. detail remains accepted for compatibility with
external adapters.errors.public_exception into {"error": "internal
error", "code": "internal_error", "status", "request_id"}, and the original
goes to the redacted unhandled_exception diagnostic that
diagnostics.build_bundle collects. A GatewayError's message is
author-written and is passed through unchanged. Quote request_id in a
support report: it is this daemon's own correlation id, never an upstream
provider's.POST /api/v1/artifacts/{aid}/versions/{vid}/restore maps a soft
{"error": …} result to 404 but other handlers pass soft errors through aserror key". POST /api/v1/connectors/{id}/call
used to be in this list and now answers 502 connector_failed: api() in
the web client only rejects on a non-2xx, so a connector that never ran was
reported to the user as one that did.Most routes return JSON. The exceptions return raw bytes with a guessed
or stored Content-Type:
| Route | Body | Notes |
|---|---|---|
GET / , GET /index.html, unknown non-API GET |
HTML | SPA shell from webui/dist/index.html (or webui/index.html when OPENAI4S_WEBUI=legacy). |
GET /static/<rel> |
file bytes | Path-traversal-guarded; 404/403 as JSON. |
GET /api/v1/artifacts/{ident} |
artifact bytes | Compatibility route. ident may be a version_id, artifact_id, or filename (in that resolution order: store.resolve_artifact_path tries artifact_versions.version_id first, then artifacts.artifact_id → its latest version; the handler falls back to a filename lookup). Content-Type comes from stored metadata, else guessed from the filename. Default (flag-off) completion links use this route. The un-versioned /api/artifacts/{ident} form that 0.2.0 wrote into stored messages is still a 404 on the server (no alias); the workbench markdown renderer rewrites exactly that one-segment form to this route. |
GET /api/v1/artifacts/versions/{version_id} |
immutable artifact-version bytes | Stage 1 trusted completion links use this reserved canonical route only. The server helper rejects empty and dot-only identifiers and encodes slash, Unicode, and URL metacharacters into one path segment. The route resolves that exact version or 404 and never falls back to an Artifact id or filename, so a reopened link remains bound to the same bytes after the head changes. |
GET /api/v1/frames/{fid}/artifacts.zip |
ZIP bytes | Current Artifact versions for one session. |
GET /api/v1/projects/{pid}/artifacts.zip |
ZIP bytes | Current Artifact versions across one project. |
GET /api/v1/frames/{fid}/notebook/export?language= |
.ipynb, ZIP or Markdown bytes |
python/r returns one Notebook; omitted/bundle returns both plus a manifest; markdown returns one .md with both languages in execution order. |
GET /api/v1/frames/{fid}/session/export |
Session ZIP bytes | Deterministic application/vnd.openai4s.session+zip; carries schema and SHA-256 headers. |
GET /preview/{ident} |
artifact bytes | Same resolution, but Content-Type is forced to text/html; charset=utf-8; ident may also be a version id, which pins that version. A filename that is ambiguous store-wide is first resolved within the frame of the referring /preview/ document (same-origin Referer), the rule the grant route applies, so a report's relative sibling answers the same in both preview modes. This app-origin preview remains script-free under a response CSP sandbox, including direct navigation. Not under /api. |
GET /sandbox/{grant}/preview/{ident} |
grant-scoped artifact bytes | Executable HTML is served only on the alternate loopback origin signed into the grant, with the minting app origin as its sole framing ancestor. The nonempty root-frame scope and deadline are checked before serving bytes; no session cookie is required or set. Relative resource paths retain the grant. Invalid/expired grants, wrong Host, a request presenting the daemon's own session cookie (a framed spend is cross-site and never carries one), foreign-frame Artifacts, API/shell paths, and non-GET methods return 404. Snapshot verification failures are recorded daemon-side under artifact:sandbox_read (and artifact:sandbox_grant at mint) without changing the response. See the bearer URL limitations. |
GET /ketcher |
HTML | Authenticated first-party document on the app origin, with same-origin framing. Flag-off: historical placeholder. Stage 9: wrapper around vendored Ketcher 3.7.0 plus the openai4s-artifact save/load bridge. |
Wart: when a raw-bytes route fails (artifact missing) it responds with a
JSON body 404 {"error": "artifact not found"} — a consumer streaming the
response to disk gets a JSON document.
Note the overlap on GET /api/v1/artifacts/…: the specific matchers
(/lineage, /environment, /versions, …) are tried first; the final
re.fullmatch(r"/artifacts/(.+)") + GET catch-all serves bytes, and because
it matches .+ (slashes included) it also catches any otherwise-unmatched
GET under /api/v1/artifacts/.
All paths below are under /api/v1 unless stated otherwise. "→" describes the
success response body. Serializer shapes are in §4.
| Method & path | Behavior |
|---|---|
GET /health (not under /api) |
Minimal public projection {"status":"ok","model"}. Exempt from the token gate and deliberately omits host filesystem paths. |
GET /me |
Local identity: {"user_id":"local-dev","email":null,"provider","has_api_key","shared_api_key":false,"auth_mode":"token"}. Behind the credential gate (401 without one). auth_mode is always "token": the loopback OPENAI4S_REQUIRE_TOKEN=0 opt-out was removed in 0.3.0 (§1). |
GET /auth/status |
{"authenticated":bool,"auth_mode":"token","token_header":"X-OpenAI4S-Token"}. Exempt from the gate, so a client can discover it needs a credential; authenticated says whether this request carried a valid one (§1). Never any part of the token. |
POST /auth/login · POST /auth/logout · GET /auth/me |
Team mode (M1). HttpOnly SameSite=Lax cookie; only the token's sha256 is stored. Login is rate-limited per username+IP and the bucket is charged before the password hash, so the limit also bounds the hashing an attacker can provoke. Wrong password, unknown user and disabled account are one sentence — the difference is the attacker's question. |
GET /auth/me/llm-key |
Whether this user has a key of their own, per provider: {keys: [{provider, configured, created_at, updated_at}]}. Never the value — a credential a screen can display is one a screenshot leaks — and never the reference either, which names a keychain slot. |
PUT /auth/me/llm-key |
Body {provider, api_key} (M4-1, decision D7's second half). The key goes to the SecretBroker; the database keeps only a reference. A broker that cannot store it answers 503 secret_store and writes no row: a row pointing at a slot that was never filled would make the next turn refuse with "configured but unreadable" for a key that was never accepted. The override is per provider, so a user with their own Anthropic account and no OpenAI key runs on theirs for one and the group's for the other. |
DELETE /auth/me/llm-key |
Body {provider} → {ok, removed, provider}. removed:false when there was nothing to clear, which is not an error: the intent — "do not use my key" — is satisfied either way. Disabling an account clears every key it had, because a credential that outlives its account is one nobody is watching. |
GET /csrf |
{"csrf_token":"local"} (a stub; the real CSRF defense is the Origin check). |
GET|POST|PUT|PATCH /config/llm |
GET → {provider,model,base_url,has_api_key}. Write → persists provider/model/base_url; api_key only overwrites when non-empty; clear_api_key:true empties it. Changing provider without a replacement key also clears the old provider-bound credential so it cannot be reinterpreted or sent to the new provider → {"ok":true,"has_api_key"}. The raw key is never returned. |
GET /search?q= |
{sessions:[{id,project_id,name,task_summary}], artifacts:[{id,filename,content_type,root_frame_id,project_id}], datapro:[{query,dataset_type,json_pointer,content,artifact_id,root_frame_id,project_id}]}; empty q → empty lists. datapro searches every recursively indexed key and scalar in successful DataPro content; each hit is a logical result occurrence, so equal records at different JSON pointers remain distinct. |
GET / (i.e. /api or /api/v1/) |
{"service":"openai4s","ok":true}. |
| Method & path | Behavior |
|---|---|
GET /onboarding |
Redacted first-run state: the active provider/model/base_url, has_api_key, saved profiles, the local-model catalogue and environment/network posture, plus one complete flag the wizard opens on. Derived entirely from stored state, so opening the wizard contacts no provider and cannot be used to probe whether a key works. No credential value appears in the payload. complete is true once POST /onboarding/complete (or openai4s init) wrote the flag, or when the install is evidently past its first run: a stored model configuration (llm_* settings, an active or saved non-deleted profile) or history: any session that has held a message, or any executed Cell (a Notebook REPL cell records an execution and never a message, so an install used only through the Notebook counts; a session created and never run does not). 0.2.0 had no Web wizard and never wrote the flag from the Web UI, so without that rule an upgraded install reopened behind a first-run modal over its own history. An environment key alone does not count — with no history and no stored configuration that is what a fresh .env install looks like. The GET never writes the flag. One consequence is accepted: the wizard saves and activates a profile as soon as a new model is chosen, so a page reload after that choice, part-way through a fresh install's wizard, hydrates as complete and does not reopen the remaining steps. |
POST /onboarding/complete |
Marks first-run finished. In team mode this is admin-only (403 admin_only): the state is installation-wide, so a member dismissing it would be deciding for everyone. |
All three are admin-only in team mode on every verb (403 admin_only, never
404) and answer with Cache-Control: no-store.
| Method & path | Behavior |
|---|---|
GET /diagnostics/status |
Passive security posture (security, environment, request_id): permission stats plus the sandbox / egress / secret-store / confinement env knobs. No network, no child process, no Store open. |
POST /diagnostics/checks |
The full doctor.report() — the same probes the CLI doctor runs, side effects included (a temp sandbox self-test, environment root walks). {"status","checks":[…],"request_id"}. |
POST /diagnostics/bundle |
Streams the redacted support zip (application/zip, Content-Disposition: attachment). The body is ignored — the client cannot name the output path. One generation in flight per process and one per principal per 60 s (429 rate_limited; a 413 or a failed generation does not spend the cooldown); 413 payload_too_large above 32 MiB. |
Readiness is local; reachability is asked for. Every profile carries a
readiness object — ready / needs_key / needs_model / unsupported —
derived entirely from stored state, so listing profiles costs no network at
all. checked_endpoint is always false there, and ready means the
configuration is complete, not that anyone answered; the detail line says so,
because a user who read it as "verified" would be reading a stronger claim than
the data supports.
POST /model-profiles/{id}/probe is the only thing that contacts a provider.
POST rather than GET because it spends the user's own quota and rate limit, and
a GET invites a prefetch or a refresh loop to spend it for them. It refuses
without contacting anything when readiness is not ready — a keyless profile's
401 reads like an endpoint fault rather than the missing credential it is. A
failure reports the provider's own message, redacted, because a rewritten one
loses the detail that distinguishes a bad key from a bad model name.
gemini and openai_responses are selectable protocols. Both were dispatchable
by the LLM layer and absent from the profile menu, so a user holding a Gemini
key had no way to say so.
A session binds profile_id + revision. A frame used to store a model
string, which answers "which model name" and not "which configuration" — and
the two come apart in exactly the case that matters, because two profiles can
name the same model against different providers or endpoints, and editing a
profile rewrote it in place. A replayed session therefore reported whatever its
profile says today.
revision and an append-only revisions[]. A new
revision is minted only when (provider, base_url, model) changes. A rename
does not, and neither does a key rotation — the credential reference is
derived from (scope, profile_id), so revisions must share the profile id or
rotating a key would strand earlier revisions on an unreadable secret and
deleting any revision would destroy the key the others point at.409 model_revision_unavailable — the session is pinned to a revision that
no longer exists, or whose credential no longer resolves. Resolving to the
nearest one would be the silent follow-latest behaviour this replaces,
wearing a number. A pinned revision whose provider or effective endpoint is
not the one the profile names now is refused the same way (an empty
base_url is resolved the way dispatch resolves it:
OPENAI4S_<PROVIDER>_BASE_URL, then OPENAI4S_LLM_BASE_URL, then the
protocol's default): the profile's key is shared across revisions and
belongs to its current configuration, so it is never sent to an older
revision's endpoint — nor is an environment key substituted for it. A
model-only revision keeps provider and endpoint and still dispatches.409 model_profile_needs_key — the profile a send would pin (the active one,
or a legacy session's unique match) has no usable credential, so it is not
pinned at all. Rebinding would land on the same profile; the answer is a key..local or host.docker.internal) — checked before any
inherited key, so a cloud credential such as OPENAI_API_KEY is never sent
over plain http to a local server; a local server that needs a key gets the
one saved on its profile; else a key the daemon's environment holds for the
same provider (OPENAI4S_<PROVIDER>_API_KEY, the provider's native
variable, or — for the daemon's own provider only — its resolved key and an
operator-injected llm credential), never another provider's. A brokered key
that no longer resolves is refused rather than replaced by an environment key.409 model_revision_ambiguous — a legacy session whose recorded model
matches more than one live profile. Backfill happens only on a unique match;
an ambiguous one stays unbound and asks, because picking either would be a
guess presented as a fact. A deleted (tombstoned) profile is never a
candidate: it is history, not a configuration to continue under.POST /frames/{id}/model-binding answers both model_revision_unavailable
and model_revision_ambiguous by re-pinning the session to the active
profile — what the client's confirmation prompt says — and never through the
legacy backfill. The active profile's credential is checked before the old pin
is dropped, so a refused rebind (409 model_profile_needs_key) leaves the
session's record untouched. With no active profile the rebind takes the
decision the next send would: the session is left unbound on the global
configuration, or backfilled to the one live profile its recorded model names,
and binding says which (bound, backfilled). When that send would be
refused again — several live profiles match, or the unique match has no
usable credential — it answers 409 model_profile_needs_active ("activate a
profile in Customize → Models") before dropping the old pin, rather than a
200 the next send contradicts..env) binds nothing and runs.
An absent profile is an absent binding, not an error.| Method & path | Behavior |
|---|---|
GET /models |
{"models":{"default":[{id,name,description}…]},"default_model_id"} — the live model first, then the saved profiles' models, deduped. Built-in provider defaults are not listed: an endpoint the user never configured must not be selectable. A profile that leaves model blank is resolved through its protocol's default. |
GET /models/default |
{"default_model_id"}. |
POST /models/default (any non-GET) |
Body {model_id} → persists as llm_model setting → {"default_model_id"}. |
GET /model-endpoints/discover?force=1 |
Explicitly probes the fixed loopback catalogue for Ollama, LM Studio, vLLM, and llama.cpp, with environment proxies disabled. Returns sanitized profile suggestions plus mutated_settings:false; it never accepts a caller-supplied URL and never creates or activates a profile. force=1 bypasses the short in-process cache. A discovered endpoint is keyless, but vendor capabilities are not inferred: until an explicit override exists it uses conservative Code-as-Action (no inherited vision/tool/schema claim). |
GET /model-profiles |
Returns only user-saved profiles as {"profiles":[masked…],"active_id","protocols":["chatgpt","claude","ark"]}; no default endpoints are seeded. A one-time migration removes entries matching the preset identities generated by older releases. Profiles are masked: {id,name,provider,base_url,model,has_api_key,credential_source,readiness,…} — the API key is never echoed. has_api_key says whether the profile holds a key of its own; credential_source says where the credential it is dispatched under comes from (profile / environment / local / revoked / missing). |
POST /model-profiles |
Body {name,provider,base_url?,model?,api_key?} where provider selects the chatgpt (OpenAI-compatible), claude (Anthropic-compatible), or ark protocol; missing name or an unsupported protocol → 400 {"error":…}; success → 201 masked profile. |
POST /model-profiles/{id}/activate |
Copies the profile's fields into the live llm_* settings, moves it to the front of the list → {"ok":true,"active_id","has_api_key"}; unknown id → 404. |
PUT|PATCH /model-profiles/{id} |
Partial edit; api_key only overwrites when non-empty; clear_api_key:true clears. Editing the active profile also syncs the live settings → masked profile; unknown id → 404. |
DELETE /model-profiles/{id} |
Removes it (clears active_model_profile if it was active) → {"ok":true}. Deleting a nonexistent id still returns {"ok":true}. |
These routes wrap the official arkcli; OpenAI4S does not implement a second
Volcengine OAuth client. Public responses allowlist display name, project,
region, plan metadata, quota periods, and connector state. IAM user/account
identifiers, tokens, authorization codes, and API Keys are never returned.
| Method & path | Behavior |
|---|---|
GET /volcengine/connection |
Returns {installed,version,state,identity,plans,usage,access,login,cached,linked,configured,configured_plan_key,model_profile}. Account state is independent from access.state: a successfully connected account may still report no_plan, key_missing, key_choice_required, key_check_failed, profile_missing, plan_inactive, seat_required, quota_exhausted, platform_endpoint_required, endpoint_choice_required, endpoint_check_failed, platform_ready, plan_choice_required, or check_failed. ready configures a Plan; platform_ready configures a pay-as-you-go API Key plus Endpoint. Key and Endpoint choices use in-memory opaque references rather than cloud identifiers. linked means the dedicated masked model profile exists, while configured means it is also active. Read projections are cached briefly, while the in-flight login state remains live. |
POST /volcengine/refresh |
Performs one fresh identity, plan, usage, profile, API Key, and Endpoint projection. Key and Endpoint inventories are queried live, so console changes are discovered without copying credentials into OpenAI4S. A unique usable Platform Endpoint is eligible for immediate automatic configuration. Returns the same public shape as connection; Key material is never returned. |
POST /volcengine/login |
Body {mode:"browser"|"device"}. Both modes use the same cross-platform two-phase arkcli auth login --no-browser flow and return 200 {state:"awaiting_code",login_id,method:"browser_oauth",phase:"authorization",authorize_url,expires_at}. browser remains a compatibility alias; no system terminal is opened. |
POST /volcengine/login/complete |
Body {code} completes the pending browser authorization. Ark's normal value is the complete Base64 authorization string containing code and state; for convenience, OpenAI4S also accepts the inner code and binds it to the pending authorization state. The bounded value is passed only as an Ark CLI argument and is never persisted or echoed. |
POST /volcengine/login/cancel |
Cancels the pending browser authorization and returns its public state. It does not log the user out of Ark CLI. |
POST /volcengine/configure |
Body {plan_key?,api_key_choice?,endpoint_choice?}. A single available Plan or a unique Platform Key + Endpoint pair is used automatically; ambiguous inventories require the corresponding opaque choice. The connector validates every choice against a fresh Ark inventory, resolves the raw Key only inside the backend, creates or updates one dedicated Ark model profile, brokers the Key, activates the profile, and returns 201 with masked profile/connection data only. |
POST /volcengine/disconnect |
Body must be {confirm:true}. Removes only the dedicated OpenAI4S model profile and its brokered credential. The shared Ark CLI login remains intact for other local tools. |
| Method & path | Behavior |
|---|---|
GET /projects |
{"projects":[project…],"total":n} on an unparameterized request (compatibility full dump). With q, limit, cursor, or offset, the envelope also carries next_cursor and has_more. limit is 1–100 (default 100, values above 100 are capped). Official clients page by opaque keyset cursor on (last_active_at DESC NULLS LAST, project_id DESC); offset is honoured only for one compatibility window and must not be combined with cursor. q searches project name and description only: trim, at most 128 Unicode code points, parameterized LIKE … ESCAPE with \, %, _ treated as literals, ASCII case-insensitive and non-ASCII exact. total is the exact count after the team-visibility filter and q, not after LIMIT. An illegal cursor or one bound to a different q / principal returns 400 invalid_cursor and does not restart at page one. |
POST /projects |
Body {name?,description?,context?} → project JSON (with conversation_count: 0). |
GET /projects/{pid} |
Project JSON, or {} when not found (not a 404). |
GET /projects/{pid}/action-timeline?limit= |
Bounded cross-session safe Timeline projection with session labels. |
GET /projects/{pid}/lineage?limit= |
Project-wide Artifact/version lineage graph with bounded nodes/edges. |
PUT|PATCH /projects/{pid} |
Updates name/description/context → project JSON. |
DELETE /projects/{pid} |
Deletes project + frames, unlinks artifact files and session workspaces → {"ok":true,"freed_files","freed_sessions"}. |
GET /projects/{pid}/notes |
{"notes":[note…]}. |
POST /projects/{pid}/notes |
Body {content} → note JSON. |
DELETE /notes/{note_id} |
{"ok":true}. |
GET /projects/{pid}/folders |
{"folders":[…]}. |
POST /projects/{pid}/folders |
Body {name} → folder row. |
PUT|PATCH /folders/{fid} |
Rename → {"ok":true}. |
DELETE /folders/{fid} |
{"ok":true}. |
POST|PUT|PATCH /frames/{fid}/folder |
Body {folder_id} (or null) → {"ok":true}. |
GET /example/session |
{seeded,frame_id,project_id,started,running,seeds_at_startup,error} — state of the bundled example analysis. started is always false on a GET. |
POST /example/session |
Body must be {"confirm": true}; anything else is 400 confirmation_required and seeds nothing. Idempotent: already seeded → {"seeded": true, "started": false}; already running → {"started": false, "running": true}, which is distinguishable from a refusal. Seeding happens on a background thread, so this returns immediately and the client polls the GET. |
@file references are version-pinned. @name#v-<version_id> sends the
frozen bytes of that exact version; it used to read the artifact's live path,
so the same reference meant different bytes once a later cell overwrote the
file. A same-project reference belonging to another session is materialised
into this one (D3) when the turn is sent — not when the reference is typed, so
an inserted-then-deleted reference leaves no Artifact and no lineage edge
behind. Cross-project is refused with the same answer as absent.
The bare @name spelling is deprecated: it still works, and is removed in 0.4.0
(artifact_refs.LEGACY_REF_REMOVED_IN, which a test fails on). Until then it
resolves inside the calling session only, through the artifact's latest
version rather than its live path, and says in the injected block that it is
unpinned. Use the pinned @name#v-<version_id> form.
With stage1_trusted_delivery enabled, ordinary messages remain routable while
the standard profile is incomplete. This is required for native control-tool
and sole finalize_response turns, neither of which needs a kernel. If routing
selects a Code Cell, needs_setup/needs_repair fails it with
environment_not_ready; an unreadable or ambiguous local inventory uses
environment_readiness_unavailable. The refusal occurs before a pending
environment switch, Cell identity/attempt, runtime start, or workspace side
effect, and the terminal job/WebSocket projection carries the stable code. The
complete structured gaps and copy-only repair commands come from
GET /environments/status. Approved/resumed plans retain a synchronous
pre-CAS check because their execution contract requires scientific Cells;
plan:true drafting remains available.
In team mode, project visibility grants the frame GET surfaces below, not
write authority. Every mutating /frames/{fid} request is owner/admin-only;
the D4 visibility toggle is stricter and remains owner-only, while the
POST-shaped Revert preview is the explicit read-only exception. Mutating
Artifact, annotation and share resource routes, plus body-addressed uploads,
resolve their owning root and enforce the same rule.
| Method & path | Behavior |
|---|---|
GET /frames?project_id=&limit=&cursor= |
{"frames":[…],"next_cursor":…,"has_more":bool}. Keyset pagination, newest first; limit 1–200 (default 100). cursor is opaque — parsing it would couple a client to the sort key. An unreadable cursor is a 400, never a silent restart, which would loop a client on page one. has_more is observed by collecting one row beyond the page, not inferred from the page being full: hidden abandoned sessions are filtered after the read, so a full-looking page is not evidence of a next one. |
POST /frames |
Body {project_id,model?} → frame JSON for a new root frame. project_id is required: a missing, empty or non-string id is 400 project_id_required (create a project with POST /projects or list them with GET /projects); an id with no project row is 404 not_found. |
GET /frames/{fid} |
Frame JSON, or {} when not found. |
GET /frames/{fid}/auto-mode |
Durable Stage 2 logical-branch projection: feature/writable state, effective selection and precedence source, deployment metadata, read-only hard budget ceilings, sanitized current run, and last committed event identity/cursor. It never returns prompts, hidden rationale, permission payloads, or reusable authorization. |
PATCH /frames/{fid}/auto-mode |
CAS selection update. Body requires revision and may set only preset, result_review_mode, and approvals_reviewer; setting all three to null clears the frame override. Disabled storage is 409, stale revision is 409, and imported quarantine is 423. It changes configuration only and starts no model or action. |
GET /frames/{fid}/auto-audits?subject_kind=&before=&limit= |
Newest-first sanitized durable audit summaries for the active logical branch. subject_kind is result_review or permission_review; before is an event cursor/id and limit is 1–500 (default 100). The response contains no raw assessment prompt, hidden rationale, permission request, or authorization capability. |
PATCH /frames/{fid} |
Updates name/task_summary, broadcasts frame_update → frame JSON. |
DELETE /frames/{fid} |
{"ok":true}. |
GET /frames/{fid}/messages?from=&limit=&branch_id= |
Branch-projected {"messages":[{message_id,role,content,created_at,fork_checkpoint_id,artifact_refs,failure?,cancelled?,review_status?,turn_id?,execution_id?}…]}. A gated assistant row exposes the allowlisted review_status:{status,unverified,user_truth} plus its turn/execution identity so REST and WS replay can upsert the same canonical candidate. failure is present only on a message that recorded one, and carries {request_id,code,output_committed?} — an allowlisted projection of the row's metadata, never the exception. It exists because reopening otherwise lost both the support id and the retry veto: the socket event is gone once the tab closes and the stored row is a sentence. output_committed appears only when true; absent is "no claim". cancelled is present only on the stopped marker that closes a cancelled turn — a trailing assistant row written whether or not the turn streamed prose, in the request's language (_Stopped by user._ / _已由用户停止。_, or the Auto Mode budget variant) — and carries the allowlisted {request_id,execution_id,reason} with reason one of user (an explicit cancel) or auto_budget (an Auto Mode budget denial ended the turn). Omitted branch_id selects the durable active branch; its inherited prefix and post-Revert continuation are included, while sibling/abandoned rows remain only in the audit source. from (default 0) and limit (default 300) are real slice parameters. Latest-first paging: ?newest_first=1 returns the newest page and adds next_before_seq + has_earlier; ?before_seq=<seq> walks backwards. Without either, the response is exactly what it always was — oldest-first from from, and no cursor keys. It mattered because a 640-message session returned messages 0–299: the oldest page, with the newest 340 absent. The cursor is a seq bound rather than an offset, because newest-first plus OFFSET shifts on every arriving message. has_earlier is observed, not inferred from a short page — the branch projection can hide rows, which a client cannot tell from the end of history. before_seq that is not an integer is 400 invalid_cursor. |
GET /frames/{fid}/steps |
{"steps":[…]} (persisted semantic steps). |
POST /frames/{fid}/message |
Starts a turn. Body {request} (or {input_data:{request}}), optional model, plan, explore, task_mode, annotation_ids. task_mode is one of analysis_run (default) / reusable_pipeline / codebase_change; an unrecognised value is 400 invalid_task_mode. A non-default mode appends a guidance fragment to the turn's user message (the stored message row is unchanged). Only an EXPLICIT task_mode additionally makes the Host require and verify source_files / entry_points / architecture_summary / test_evidence on that turn's completion; omitted, the mode is classified conservatively from the request text and stays advisory — the fragment (with an honest advisory note) is appended, but completion is never gated, so a classifier false positive cannot refuse an honest answer. plan:true drafts a reviewable plan instead of executing: the turn gets no tools, and unless the request already opens with plan-mode text ([Plan Mode] / [计划模式], which the workbench prepends) the server appends a plan-draft instruction to the turn's model input — Chinese when the request contains CJK, otherwise English — asking for a short approach plus a fenced json block {title, rationale, confidence, steps:[{id,title,detail,deliverables}]} (the stored message row is unchanged). A captured draft emits plan_ready and is readable at GET /frames/{fid}/plan. A plan turn's result (the waited response and the job result) carries plan_captured (boolean; absent on non-plan turns), and a plan turn that completes without yielding any steps emits plan_not_captured rather than plan_ready — its status is still completed, because the model did answer, but there is no plan to approve. With wait:false → 202 {"status":"accepted","frame_id","job_id","execution_id","owner":{"kind","id"},"queue_position","request_id"}; request_id is the local id this turn will be named by everywhere else — the failure frame_update, the job result, the persisted assistant message and GET /frames/{fid}/messages all carry the same one, and a wait:false client has no other synchronous chance to learn it; default (wait omitted/true) blocks for the turn result. |
When annotation_ids are sent, both branches additionally carry annotations and annotation_reservation_id. Admission is exactly-once: the pins named are claimed atomically, only what was actually claimed is quoted into the prompt, and annotations says what became of them — sent (consumed), pending (the turn was accepted but the consume did not confirm; they are still reserved, so neither retry them nor treat them as gone) or none (this request claimed nothing, because a concurrent turn won the race, and they are still the user's to send). The fields are absent when no pins were sent: an absent field and a field saying "none" are different claims. annotation_reservation_id is what GET /frames/{fid}/admissions/{reservation_id} is asked about after a lost response — a dropped connection, a closed tab, a reload — and it is scoped to the frame, so it is a value a client holds rather than a capability. A synchronous refusal (413/409/429, or a worker that could not be started) releases the reservation and leaves the pins open; a failure with no response is not a refusal and must not be treated as one. A valid sole finalize_response is an Engine completion (even if an earlier step ran a Cell); host.submit_output(...) is the only completion emitted from inside a Python Cell. Ordinary prose/results and max-turn exhaustion are not success. |
| GET /frames/{fid}/execution | Authoritative FIFO snapshot: {root_frame_id,owner,queue,queued_count,active_count,closed,close_reason}. Owner/queue entries include execution_id, {kind,id} owner, status, position, branch/language/generation and resource keys when known. |
| POST /frames/{fid}/cancel | Scoped cancellation. Body {execution_id,owner:{kind,id}} (or owner_kind + owner_id) and optional reason → {ok,execution_id,owner,scope,…}. Missing identity returns HTTP 400 with error; stale/mismatched identity returns ok:false. A queued cancellation does not affect the active owner. |
| GET /frames/{fid}/status | {"frame_id","running","status",kernel:{…kernel status…}}. status is the frame's stored status, which running cannot express: running:false is equally true of a session that completed, one that was cancelled and one that failed, so a client reopening a session could not tell which and could not restore a failure that had already ended. |
| POST /frames/{fid}/feedback | Body {key,rating} → {"ok":true}. |
| GET /frames/{fid}/feedback | {"feedback":[…]}. |
| GET /frames/{fid}/session/export | Raw deterministic Session-package ZIP with X-Content-SHA256 and X-OpenAI4S-Session-Schema. It contains branch-owned messages, complete sanitized provider groups/wire state, Notebook and Artifact/lineage records, Revert cursors, evidence reviews and checkpoint plan/review/memory snapshots; secret material is rejected. |
| POST /sessions/verify | Raw Session ZIP body (same limit as import) → HTTP 200 with the verify_package report: {ok, format, schema_version, archive_sha256, files_verified, problems, verifies}. Reads only the archive — no daemon state, no network, nothing admitted to the database — so a recipient can check what they were handed before deciding to import it. The frontend calls this before /sessions/import and refuses the import when it fails. verifies states plainly what the check does not establish: internal consistency is not authorship, which would need a signature. |
| POST /sessions/import | Raw Session ZIP body (not JSON, maximum archive 128 MiB) → HTTP 201 with new {project_id,root_frame_id,active_branch_id,kernel_state:"ended",view_only:true,trust_state:"quarantined",explicit_recovery_required:true,…}. The entire archive is preflighted as untrusted input, all identities are remapped, permissions are downgraded, review automation is disabled, and no Kernel/hook/package code starts. The quarantine is durable: frame-scoped mutations return HTTP 423 until the user calls POST /frames/{fid}/recovery/actions/restart_fresh with {"confirm":true}; read/export/delete remain available. |
| Method & path | Behavior |
|---|---|
GET /frames/{fid}/plan |
{"frame_id","plan_id","status","plan"} (nulls when no plan). |
POST /frames/{fid}/plan/approve |
202 {"status":"accepted","frame_id","job_id","request_id","execution_id"} — auto-execution runs in the background; request_id correlates with the failure surfaces exactly as the message route's does. execution_id is a real coordinator execution taken at submit, so the 202, the job poll, the socket and any failure all name the same one; a plan turn is queued behind the running turn and holds the session until it has written its own outcome, exactly like a message turn. The approve/resume routes claim the plan row before answering, and the background turn always settles that claim — failed when it faults, paused when it is cancelled, including a cancellation that arrives while it is still queued. |
POST /frames/{fid}/plan/resume |
202 {"status":"accepted","frame_id","job_id","request_id","execution_id"} — runs only the plan's unfinished steps. 409 plan_not_paused when the plan is any other status, refused synchronously: the paused → executing transition is a compare-and-swap performed before the 202, so of two concurrent resumes exactly one is accepted and the other is refused with the status it lost to, instead of both being handed a job that runs the same steps. A step counts as settled when it is completed or failed: failed is a decision the agent made and moved on from, while in_progress was interrupted with no record of how far it got, so it is re-run. The resume seed names the settled steps and instructs the agent not to redo them. A paused plan with nothing unfinished is marked completed without running a turn. |
POST /frames/{fid}/plan/revise |
Body {changes} (or {feedback}); empty → 400 {"error":"changes required"}; else 202 {"status":"accepted","frame_id","job_id","request_id","execution_id"}. The revision runs as a plan:true turn whose seed already opens with plan-mode text, so no second draft instruction is appended. |
POST /frames/{fid}/plan/discard |
Result of runner.discard_plan (synchronous). |
Approve, resume and revise each run an ordinary turn whose stored user message is a server-written seed. Seeds are written in the plan's language: Chinese when the request that drafted the plan (the newest user message stored no later than the plan row), the plan's own text, or the revision feedback contains CJK; otherwise English. The turn's narrations and completion headings take their language from that seed, so an English plan executes with English Metrics: / Completed work: headings.
Under the Stage 1 flag, approve and resume run the same standard-profile
preflight before their draft/paused compare-and-swap. A readiness refusal
therefore uses the 409/503 codes above and leaves the plan in its prior state;
it cannot strand a plan as executing without an admitted job. Draft/revise
remain non-executing authoring operations and are not readiness-gated.
Long unattended work: submitted here, executed by whatever resource plane the daemon has (this machine by default, a scheduler when cluster.toml configures one). The API is deliberately backend-neutral — a response names an allocation_id, never the scheduler's job id, and a profile name, never a queue (INV-2, decision D5).
Nothing here submits synchronously. A request writes a durable row; the reconciler loop does the talking on its next pass. That is what makes a cancel survive a daemon restart, and what keeps one submission from being attempted twice by a request thread that goes away mid-flight.
| Method & path | Behavior |
|---|---|
POST /orchestration/jobs |
Body {command: [...], profile?, backend?, workdir?, environment?, project_id?}. 202 {id, phase, ...} — accepted, not started; 201 would promise a resource that has not been granted. command must be a list: a string is refused with 400 invalid_command, because splitting a command line is where quoting bugs become injection. Unknown profile → 400 unknown_profile; unknown backend → 400 unknown_backend with the available ones. In team mode both built-in backends are admin only (403 admin_only, with the refused backend named) — see below. |
GET /orchestration/jobs |
{jobs: [...]}, filtered to the caller's own unless they are an admin. project_id, limit and all=0 (hide terminal) narrow it. |
GET /orchestration/jobs/{id} |
One job plus its allocation (the live attempt) and allocations (every epoch). Someone else's job is 404, not 403 — which jobs exist is itself information about what a colleague is working on. |
POST /orchestration/jobs/{id}/cancel |
Records the desire to stop and returns {ok, reason}; the reconciler runs the cancel barrier. 409 already_final when the job has already ended. An admin cancelling another user's job is recorded as ADMIN_CANCELLED rather than USER_CANCELLED. |
GET /orchestration/jobs/{id}/logs |
{allocation_id, stdout, stderr} — the tail (64 KiB) of what the job wrote. |
GET /orchestration/profiles |
{cluster, configured, profiles: [{name, cpus, memory_mb, gpus, walltime_s, nodes}]}. The queue and service-class each profile maps to are not in this payload: they live in cluster.toml and nowhere else. |
Which backends a member may name. Neither built-in backend is available to a member in team mode. local runs Popen(argv) as the daemon's uid, outside the kernel sandbox, with the daemon's filesystem access. cluster invokes the scheduler with the daemon's Unix identity and site credential; OpenAI4S has no authenticated mapping from a browser member to a scheduler account. Both therefore spend or expose instance resources and are admin only, by the same rule that makes /compute/jobs admin only.
The check runs after the backend name is validated and before anything is written, so a misspelled backend still reads as 400 unknown_backend rather than a permission problem, and a refused submission leaves no workload row and no audit entry behind.
A single-user daemon is unaffected (INV-1): without team mode there is no member/operator distinction, because the person running the daemon is the operator.
Note what this does not do: it does not put LocalBackend inside the kernel sandbox. That sandbox degrades visibly rather than failing closed on hosts that cannot give it namespaces, and a privilege boundary that is sometimes absent is worse than a refusal. Member-submitted local work wants a separate, fail-closed local-sandboxed backend designed as one.
A session's kernel is on the daemon by default. Asking for it to be on a granted resource instead is these three routes, and the answer they exist for is readiness: a cluster session is not one boolean but four conditions (INV-5), so the payload names the one that is outstanding. "Queued for a node", "waiting for the worker to dial in" and "starting the kernel" are three different waits with three different expected durations, and one spinner for all of them is how a user concludes the product is broken.
| Method & path | Behavior |
|---|---|
GET /sessions/{id}/compute |
{session_id, location} — location:"local" with workload:null when this session runs on the daemon, which is the default and is not an error. On a cluster session: readiness:{ready, blocked_on, allocation_granted, worker_registered, workspace_ready, kernel_ready}, workload:{id, profile, phase, desired_state, reason, execution_epoch}, allocation:{allocation_id, epoch, phase, reason}, lease:{idle_ttl_s, max_lifetime_s, last_active_at, created_at, released_at}, and state_lost_epochs — the epochs whose kernel memory was lost to a node failure. Someone else's session is 404, not 403. |
POST /sessions/{id}/compute |
Body {profile}. 201 with the same status payload only when the selected backend explicitly provides verified per-allocation OS isolation. A backend without that optional capability returns 409 {ok:false, code:"remote_isolation_required", error, backend} before any session-keyed workspace, workload, lease, allocation, or bootstrap credential is written. The built-in LocalBackend and Slurm backend do not claim the capability, so interactive SESSION placement through them currently fails closed; BATCH submission is unaffected. A shared Unix uid plus 0700/0600 modes is insufficient because a sibling allocation can still read the unused credential. A profile the operator has not configured is 400 unknown_profile rather than a guess: guessing is how a session lands on resources its owner never chose. A daemon with no worker listener answers 409 not_configured and says which variable turns one on — the listener is off by default because a listener on every laptop that will never run a cluster job is an attack surface, not a convenience. In team mode this request is also admin only: the scheduler is invoked with daemon-managed identity or credentials, not an authenticated account belonging to the browser member. |
POST /sessions/{id}/compute/release |
Records the desire to stop and ends the lease; the reconciler runs the cancel barrier. {ok, session_id}. In team mode only the session owner or an admin may release it; project visibility alone does not confer control of another user's allocation. |
state_lost_epochs is what the UI turns into a banner. When a node dies the kernel's memory dies with it — variables, imports, the seed somebody set three cells ago — and the session continues on a new epoch. Saying so is mandatory (INV-11): the results afterwards look exactly like results from the session that was lost.
| Method & path | Behavior |
|---|---|
GET /attention?limit=&cursor= |
Read-only aggregation of what is waiting across every visible session: {items, next_cursor}. An item is {id, source_kind, source_id, state, severity, frame_id, project_id, title, updated_at, target:{surface,dock,frame_id}, action_hint}. Four closed sets: source_kind ∈ running/queued/approval/recovery/blocked/compute, severity ∈ high/medium/low, target.surface ∈ session, target.dock ∈ timeline/recovery/security/compute. They are closed so the client builds navigation locally and the server never emits a URL. Items are deduplicated by source_kind + source_id — one card per fact, not per source that noticed it. The cursor is keyset over (updated_at, id) and bound to the caller's scope and filter fingerprint. Team visibility is applied before aggregation, ordering and limit, so a row the caller may not see cannot consume a slot. The GET has no side effects: no kernel spawn, no provider call, no harvest. Retry / approve / restore remain on their existing mutation routes. |
| Method & path | Behavior |
|---|---|
POST /frames/{fid}/decision |
Answers a pending await_permission prompt. Body {decision_id,allow,scope?("once"),pattern?,message?}. A live decision returns {ok,decision_id,allow,scope,resolution_context:"live_thread",requires_continue:false,original_action_executed:null} and wakes the exact blocked call. After daemon restart it returns resolution_context:"after_restart", original_action_executed:false, and requires_continue:true for an approval; no stored arguments are replayed. A restart once approval also returns its exact-grant continuation_expires_at and continuation_authorization; broader scopes persist a standing rule. Unknown, cross-frame, conflicting, or expired decisions return ok:false with error. |
GET /frames/{fid}/permissions |
{"root_frame_id","project_id","rules":[…]} — rules effective for that conversation. |
POST /permissions |
Upsert a rule. Body {scope("global"),scope_id?,frame_id?,tool("*"),pattern("*"),decision("ask")}; when scope_id is omitted but frame_id given, the scope id is derived from the frame → {"ok":true,"rule_id"}. |
POST /permissions/reset |
Re-seeds defaults → {"ok":true,"rules":[…]}. |
DELETE /permissions/{rule_id} |
{"ok":true}. |
| Method & path | Behavior |
|---|---|
GET /frames/{fid}/annotations?artifact_id= |
{"annotations":[annotation…]}. |
GET /frames/{fid}/admissions/{reservation_id} |
{"reservation_id","state","annotations":[id…],"request_id","job_id"}, or 404 when this session has no such admission. What a client asks after its 202 was lost. Scoped to the frame: a reservation id travels in a response, so it is a value a client holds and not a capability. |
POST /frames/{fid}/annotations |
Body {artifact_id,body (or text),artifact_name?,x?,y?} (x/y are 0–1 fractions; rel_x/rel_y accepted as aliases). Missing artifact_id/body → 400 → else 201 {"annotation":…}. The server binds the pin to the artifact's current version id + checksum; a client may not supply them. On send, that exact version's bytes are read (its immutable snapshot, else the live path verified against the checksum) — a file overwritten after the pin is refused as version_changed, never substituted. |
PATCH\|POST\|PUT /annotations/{aid} |
Body {body?,status?} → {"annotation":…}, 404, 400 invalid_status, or 409 annotation_reserved. status is a whitelist (open/sent/resolved/dismissed); reserved is not publicly writable, because that state is entered only together with its holder and a row holding nothing is released by nothing. A pin currently held by an in-flight turn refuses with 409 — the check and the write are one statement, so a reservation taken concurrently is respected rather than raced. |
DELETE /annotations/{aid} |
{"ok":true}, or 409 annotation_reserved while a turn holds the pin. Same single-statement guard as PATCH. |
Kernel status and execution-log reads are lazy: they never start Python or R.
The first Agent/user Cell starts only the selected language; a native-tool or
FinalizeAction-only turn can complete with no kernel process.
| Method & path | Behavior |
|---|---|
GET /frames/{fid}/execution-log |
{"kernels":[id…],"entries":[cell…]}; entries include stable producing_cell_id, cell_index, session-monotonic state_revision, attempt-derived generation_id (nullable for legacy rows or when no worker was acquired), kernel_id, language, origin, source/output/error, files/figures, usage, immutable retry metadata when recorded, and optional output_artifacts:[{filename,artifact_id,version_id,url}]. Each binding is confirmed by a visible producing Cell or capture observation; url addresses the immutable version. An empty/missing list never implies the latest version. |
POST /frames/{fid}/kernel/execute |
Body {code,language?,execution_id?,wait?} where language is python (default) or r; the shipped UI supplies a portable execution ID. Default/wait:false returns HTTP 202 {status:"accepted",job_id,execution_id,owner,queue_position} immediately, so a queued cell remains addressable. wait:true blocks for the completed FIFO-owned Cell result. Execution always appends and never edits history. |
POST /frames/{fid}/kernel/restart |
→ {"ok":true,"status":"restarted","generation","generation_id","frame_id"} + kernel_status WS event. In team mode this is owner/admin only. |
POST /frames/{fid}/kernel/stop |
→ {"ok":true,"state":"stopped"|"none","frame_id"}. In team mode this is owner/admin only. |
POST /frames/{fid}/kernel/start |
→ {"ok":true,"state":"running","generation","frame_id",…}. In team mode this is owner/admin only. |
POST /frames/{fid}/kernel/interrupt |
Exact ticket stop. Body {execution_id,owner:{kind,id}} (or owner aliases) identifies one ticket: a queued ticket is cancelled without touching the active writer; an active ticket requests a signal only for its frozen lease. The result's interrupted flag says whether a lease was actually signalled. Missing identity returns HTTP 400; stale/wrong-owner requests return ok:false. The shipped Notebook Stop control selects only user_repl tickets. In team mode this is owner/admin only. |
GET /frames/{fid}/kernel |
Kernel status: {frame_id,state("none"|"running"|"stopped"|"ended"),alive,generation,generation_id,generation_ordinal,last_activity_at,ended_reason,turn_running,cell_count,manual_stop,repl_enabled,env:{name,language,python_version,pending,kernel_id}}. repl_enabled mirrors OPENAI4S_NOTEBOOK_REPL. |
POST /frames/{fid}/kernel/install |
Body {packages:[…]} or {package} (+restart, default true) → pip-install report ({ok,installed,…,restarted}). In team mode this is admin only, including when the caller owns the session, because the installation changes a shared runtime environment. |
GET /frames/{fid}/environments |
{"environments":[…],"current","default","pending"}. |
POST /frames/{fid}/kernel/env |
Body {env} (or {name}) — switches the kernel to a prebuilt env (restart) → {"ok":true,"state","env","generation","language","python_version","frame_id"}. In team mode this is owner/admin only. |
When Stage 1 trusted delivery is enabled, kernel/execute also performs the
standard-profile admission before allocating a Cell id/index/state revision,
execution attempt, or runtime. A not-ready/unavailable result carries the same
stable readiness codes, so the Notebook does not manufacture a failed Cell
merely to discover the first missing import. A queued request reports that
failure through its job/terminal projection; a synchronous caller receives the
corresponding refusal directly.
Notebook REPL gate: the Notebook is a read-only execution trace by
default. The mutating kernel/* routes — execute, env, restart, stop,
start, interrupt — return 403 {"error":…} unless
OPENAI4S_NOTEBOOK_REPL is set. kernel/install is intentionally not gated:
it backs Customize → Compute rather than arbitrary Notebook execution. The
read-only GET /frames/{fid}/kernel and GET /frames/{fid}/execution-log stay available.
The REPL flag is not an authorization grant: project members may read a
project-visible session, but only its owner or an admin may Stop, Restart,
Start, interrupt an execution, or change its runtime environment. WebSocket
execution cancellation follows the same owner/admin rule. Package installation
remains admin only in team mode.
GET /frames/{fid}/kernel reports the current state in repl_enabled. When
enabled, the shipped UI provides multiline Python/R input and Shift+Enter;
every submission appends a Cell through the same FIFO coordinator as Agent and
lifecycle work.
kernel_id runtime segment: the kernel_id returned by the kernel and
execution-log routes now carries the runtime segment — python for the
default env, python — struct / python — phylo etc. when the agent has
switched conda env — so per-cell rows label which environment they ran under.
state_revision currently reuses the durable session Cell ordinal. It is a
state-change cursor used for stale/read-only UI labeling, not serialized
variable state and not evidence that an older in-memory namespace is
recoverable. generation_id is the UUID bound to the execution attempt rather
than a value reconstructed from this display label.
These routes are thin Gateway adapters over SessionDomainService and
SessionWorkbenchStateService:
| Method & path | Behavior |
|---|---|
GET /frames/{fid}/action-timeline?branch_id=&before_ordinal=&after_ordinal=&limit= |
Researcher-facing Action Ledger projection. limit defaults to 500 and must be 1–500. Without a cursor it returns the latest window; before_ordinal moves older and after_ordinal moves newer. Cursors must be non-negative and mutually exclusive (invalid values → 400). Fields are bounded/redacted and raw arguments/provider wire state are omitted. Canonical usage is included; cost is non-null only when explicit deployment price metadata was recorded. Response metadata includes count, total_count, truncated, has_earlier, has_more, first_ordinal, and last_ordinal. |
GET /frames/{fid}/execution-queue |
Alias of the authoritative execution snapshot (/execution). |
GET /frames/{fid}/context |
Safe token-composition projection: totals/limit, message count, handoff/compaction state, and text/image/tool/wire token layers; no message content. |
GET /frames/{fid}/security |
Aggregate sandbox self-test projection plus per-language sandbox.runtimes[], durable-permission pending count, and Notebook interactive flag. Python-only and R-only sessions report the worker that actually ran; before either worker starts, state is truthfully not_started, not inferred. |
GET /frames/{fid}/delegations |
Safe durable child-agent tree, shared spawn budget, progress/terminal status, enforced override summary, and steering delivery counters. Each child carries the machine-readable task_status (completed/partial/blocked/failed, NULL for stopped or still-running children) beside its lifecycle status, plus its stop_reason and delegate frame_id. Result/output bodies and steering text are excluded from the browser projection. |
GET /frames/{fid}/branches |
Branch tree plus checkpoints and capability descriptors. A GET does not create the initial branch/checkpoint. |
GET|POST /frames/{fid}/checkpoints |
List or create immutable checkpoints. /branches/checkpoints is an alias. POST accepts branch_id, reason, expected_head. |
POST /frames/{fid}/branches/fork |
Body must select exactly one of from_checkpoint_id, from_cell_id, or from_message_id; optional name. Cell/message sources resolve only through an exact boundary checkpoint in this root session. Old history without one returns 409. The new branch has an independent workspace and remains inactive/view-only. |
POST /frames/{fid}/branches/{branch_id}/activate |
Exact FIFO lifecycle mutation. Stops the old branch runtime, atomically selects the requested branch/checkpoint side-state, and returns status: active|partial|failed plus per-dimension apply/recovery details. It never mutates the old branch history. |
POST /frames/{fid}/revert/preview |
Body {target_checkpoint_id,branch_id?} → {preview} including workspace/message/action/Notebook/artifact/env/permission differences and conflicts. /branches/revert-preview is an alias. |
POST /frames/{fid}/revert/apply |
Conflict-checked append-only revert, invalidates live kernels, returns 409 when it cannot safely apply. /branches/revert is an alias. |
POST /frames/{fid}/revert/undo |
Body {revert_checkpoint_id,branch_id?} — reverts to the recorded pre-revert checkpoint. |
GET /frames/{fid}/revert/operations |
Durable revert operation history. |
GET /frames/{fid}/recovery |
Safe Recovery Journal status projection. |
GET /frames/{fid}/recovery/actions |
Describes enabled/disabled reasons for restore, retry, and restart_fresh on the current root branch. |
POST /frames/{fid}/recovery/actions/{restore\|retry\|restart_fresh} |
Runs the advertised verified-recovery action under an exact recovery execution ticket. restart_fresh requires {"confirm":true} and never claims namespace restoration. |
GET /frames/{fid}/kernel/variables?language=python|r |
Bounded idle-only Variable Inspector projection. It never starts a stopped language worker and returns explicit Busy/Restoring/Ended/Not Started states. |
GET /frames/{fid}/notebook/export?language= |
Raw deterministic .ipynb for python/r; omitted or bundle returns a stable ZIP containing both plus a manifest. markdown returns a text/markdown rendering of the branch — both languages in execution order, because the interleaving is the record the split forms lose — with every cell's index, language and state revision in a citable heading, and failed cells kept and labelled. Anything else is 400. Includes Content-Disposition and X-Content-SHA256. |
GET /frames/{fid}/execution-sources |
The executed-code hierarchy: {"root_frame_id","truncated","frames":[{frame_id,parent_id,root_frame_id,name,kind,depth,status,order,counts:{cells,ok,error,interrupted},cells:[{id,seq,language,status,source_sha256,generation_id,environment:{name,interpreter}\|null,artifacts:[version_id…],interrupted}]}]} — the root frame (active branch) plus every descendant kind='delegate' frame recursively, in creation order. Cell metadata only; code text is served by each frame's own GET /frames/{fid}/execution-log. Deliberately the raw execution history: rows the read-only Notebook projection hides (protocol-only completion cells, non-scientific unpinned cells) are counted and listed here, so per-frame counts may exceed the entries GET /frames/{fid}/execution-log renders for the same frame. A legacy session with no child cells returns the root-only tree. Unknown session → 404. |
GET /frames/{fid}/execution-sources/export |
sources.zip: the executed source files themselves — root/cell_NNNN_<status>.py\|.R plus per-frame session.py/session.R (# %%-separated), children/<ordinal>_<name>_<frameid8>/… recursively for delegated frames, a NEW manifest.json ({version:1,root_frame_id,generated_at,truncated,frames,cells} — distinct from the notebook bundle's manifest), and a bilingual README pair warning that cells ran in a persistent kernel and single files are not guaranteed standalone. Failed/interrupted cells are included and marked in the file name; like the projection, the archive is the raw execution history, so it also carries cells the read-only Notebook hides (protocol-only completion cells, non-scientific unpinned cells). Only execution_log fields and public metadata are exported — no prompts, host payloads, cell output, or credentials. Byte-deterministic for the same durable history (generated_at derives from stored timestamps, never the wall clock). Includes Content-Disposition and X-Content-SHA256. |
GET /frames/{fid}/session/export |
Raw deterministic, manifest-hashed Session package. Exact-version completion deliveries are included; import verifies their snapshots and remaps message, Artifact, version, manifest, and URL identities atomically. An orphaned or inconsistent delivery rejects the package. |
GET /renderers |
Safe scientific renderer descriptor catalog. |
GET /artifacts/{aid}/renderer?version=&root_frame_id= |
Selects a version-bound renderer descriptor plus immutable checksum/size/provenance metadata; it never executes Artifact content. |
POST /artifacts/{aid}/sandbox-grant?version_id= |
Authenticated mint for an executable HTML preview. Optional version_id selects an exact version of this Artifact; omitted uses its current head. Success: {origin,path,expires_in} with a one-hour default, signed scope for the Artifact version, nonempty root frame, minting app origin and alternate spend origin. Unsupported deployment/origin, missing signing secret, missing root frame, or missing/corrupt version snapshot → 409; missing Artifact/version metadata → 404. The client validates origin before upgrading its inert iframe. Main-document bytes stay pinned; sibling resources use their current checksum-verified snapshots, with filename lookup scoped to that frame. No mutable-file fallback. |
GET /artifacts/{aid}/table |
Stage 9 workbench. Bounded full-dataset sort/filter/page over CSV/TSV/Parquet. Query: sort (must equal a header exactly), dir=desc (only the literal desc is descending), q_{column_header} (integer/number: trim then equal; other columns: case-insensitive substring), offset (negative becomes 0, non-integer 400), limit (default 50, clamped 1–500, non-integer 400), optional version_id (exact immutable snapshot; never falls back to latest). A snapshot above 32 MiB, or a table above 250,000 data rows, 256 columns, or 2,000,000 rectangularized cells → 413 {"code":"artifact_too_large"}. Parquet additionally preflights at most 512 row groups and 64 MiB decoded bytes before reading columns. Missing optional Parquet engine → 415 {"code":"parquet_unavailable"}. Flag-off → 403 {"code":"workbench_disabled"}. |
GET /artifacts/{aid}/table/profile |
Stage 9 workbench. Per-column type/missing/unique/min/max/mean/histogram for the filtered set. version_id is required. q_{column_header} uses the same parser as /table. Passing sort, dir, offset, or limit → 400 {"code":"invalid_query"}. Histogram bins ≤ 50. If exact unique cannot be finished inside the tracking bound, approximate is true rather than a pretended exact count. Stats are not persisted. Flag-off → 403 workbench_disabled. |
GET /artifacts/{aid}/table/export.csv |
Stage 9 workbench. Full filtered CSV (same parser for sort/dir/q_…). version_id is required. Optional spreadsheet_safe=1 prefixes formula-like headers and cells for spreadsheet downloads while preserving complete signed numeric/scientific literals; omitted or spreadsheet_safe=0 preserves raw values for direct/scientific API callers. Other values → 400 {"code":"invalid_query"}. offset/limit → 400 {"code":"invalid_query"}. Server-side chunks ≤ 1 MiB; total output above 32 MiB → 413 {"code":"artifact_too_large"}. Success is text/csv with X-Artifact-Id/X-Version-Id/X-Checksum/X-Filtered-Rows/X-Approximate. Flag-off → 403 workbench_disabled. |
GET /artifacts/{aid}/diff |
Stage 9 workbench. Unified diff between two versions (default oldest→newest), bounded to 8 MiB and 50,000 lines per version; excess → 413 {"code":"artifact_too_large"}. |
POST /artifacts/{aid}/structure |
Stage 9 workbench. Save a Ketcher mol/SMILES payload as a new version, or {unchanged:true} when the checksum matches the head. |
GET /artifacts/{aid}/pdf-text |
Stage 9 workbench. Page-quoted PDF text for locator comments; snapshots above 32 MiB → 413 artifact_too_large. |
GET /artifacts/{aid}/html-outline |
Stage 9 workbench. Element outline (id/selector/text) for HTML locator comments; snapshots above 32 MiB or excessive element/depth shapes → 413 artifact_too_large. |
The Timeline UI requests the latest 500 records first. When has_earlier is
true it exposes an explicit control that requests
before_ordinal=<first_ordinal>&limit=500, merges by durable group identity,
and keeps a maximum of 2,000 records without dropping the latest window.
The Notebook header and provenance execution view link the bundle form of the Notebook export route. Language-specific Python/R files remain directly available through the query parameter.
Stage 1 trusted completion treats a version URL as a delivery claim, not as a
generic Artifact lookup. Before the final message is visible, the server
requires a frozen regular-file snapshot whose size and SHA-256 match the exact
version and whose session/project scope matches the turn. The message and its
path-free manifest commit in one SQLite transaction; a snapshot, checksum,
scope, relation, or persistence failure emits no success link. The event then
uses the canonical server helper's /api/v1/artifacts/versions/{version_id} URL.
When a Cell captures bytes equal to the current head, the Stage 1 flag-on path keeps the version count unchanged and writes a durable per-producer capture observation instead. That row retains the new Cell, environment/source, and input-version lineage without rewriting the version's original producer. It is scoped local audit/delivery-delta data in this Stage. There is no standalone capture-observation route; the latest version's scope-checked observations and path-free producer frame are nested in the Artifact lineage projection so the Provenance UI can truthfully identify delegated producers. Session packages, share snapshots, and Artifact ZIPs do not yet serialize observations as portable durable records. A client-side metadata export merely mirrors the current lineage response and is not a portable observation ledger.
| Method & path | Behavior |
|---|---|
GET /frames/{fid}/artifacts |
Bare array of artifact JSON. |
GET /projects/{pid}/artifacts |
Bare array — every artifact across the project's conversations. |
GET /frames/{fid}/artifacts.zip |
Raw ZIP of the session's current Artifact versions. |
GET /projects/{pid}/artifacts.zip |
Raw ZIP of current Artifact versions across the project. |
GET /artifacts/{aid}/lineage?version= |
{"artifact_id","filename","interactions":[{kind:"cell",…}|{kind:"save",at}],"dependency_mappings":{"inputs":[…]},"producer"?:{kind:"cell"|"non_cell",frame_id,frame_kind,producing_cell_id?,cell_recorded},"capture_observations"?:[{observation_id,version_id,capture_kind,producing_cell_id,frame_id,frame_kind,cell_recorded,cell_index?,kernel_id?,language?,inputs,at}]}. Producer/capture fields are path-free and refer to the requested version, or the latest when version is omitted. An explicit missing version or one belonging to another Artifact returns 404 (artifact_version_not_found), with no latest fallback. A delegated Cell is recorded durably in execution_log keyed under its own delegate frame (frame_id = root_frame_id = <delegate frame>, origin:"delegate"), so its producer and capture-observation DTOs report cell_recorded:true with the real Cell/frame identity — while the interactions list stays save-only for it: child cells never flatten into the root Notebook projection, and no root "cell" interaction (which the UI links to a root cell index) is fabricated for them. Legacy sessions whose delegated Cells ran before child-cell recording existed (schema v28) have no such row and keep cell_recorded:false; a native writer remains non_cell with no fabricated Cell. Unknown artifact → the base shape with nulls/empties and neither optional field, HTTP 200 (not 404). |
GET /artifacts/{aid}/environment?version= |
Env snapshot captured for the producing run, {"source":"captured",…}. An explicit missing/foreign version returns 404 (artifact_version_not_found); a valid version without a recorded snapshot returns 404 (environment_snapshot_unavailable). Only an unversioned request may fall back to a live freeze {"source":"live",…}. |
POST|PUT|PATCH /artifacts/{aid}/priority |
Body {priority:int} → {"ok":true,"artifact":…|null}. |
GET /artifacts/{aid}/versions |
{"versions":[{version_id,filename,ordinal,is_latest,size_bytes,content_type,checksum?,producing_cell_id?,created_at}…]}. |
POST /artifacts/{aid}/versions/{vid}/restore |
Reverts the live file + latest pointer → {"ok":true,"artifact":…} or 404 {"error":…}; broadcasts a bare artifact_created (see §3). |
POST|PUT|PATCH /artifacts/{aid}/edit |
Body {content} (text). Non-text artifact → 415; unknown → 404 (both via GatewayError) → {"ok":true,"artifact_id","version_id","size_bytes"}. |
POST|PUT|PATCH /artifacts/{aid}/rename |
Body {filename}; missing → 400; unknown → 404 → {"ok":true,"artifact_id","filename"}. |
GET /artifacts/{aid}/table |
Stage 9: {artifact_id,version_id,filename,columns,column_types,rows,total_rows,offset,limit,sorted_by,descending,filters}. Optional version_id selects that exact snapshot. Flag-off → 403 workbench_disabled. |
GET /artifacts/{aid}/table/profile |
Stage 9: {artifact_id,version_id,checksum,filtered_rows,approximate,schema_version,columns:[{name,type,missing,unique,min,max,mean,histogram}],filters}. |
GET /artifacts/{aid}/table/export.csv |
Stage 9: text/csv body of the complete filtered set; spreadsheet_safe=1 opts into formula neutralization (the Workbench download default), while omission/0 keeps raw cells; metadata in X-Artifact-Id, X-Version-Id, X-Checksum, X-Filtered-Rows, X-Approximate. |
GET /artifacts/{aid}/diff |
Stage 9: {artifact_id,from_version_id,to_version_id,changed,diff}. |
POST /artifacts/{aid}/structure |
Stage 9: {ok,artifact_id,version_id,unchanged,structure}. Same-checksum save is a no-op. |
GET /artifacts/{aid}/pdf-text |
Stage 9: {artifact_id,version_id,pages:[{page,text}]}. |
GET /artifacts/{aid}/html-outline |
Stage 9: {artifact_id,version_id,elements:[{id,selector,text,…}]}. |
DELETE /artifacts/{aid} |
Deletes rows + snapshot files → {"ok":true} and broadcasts a bare artifact_created. If an exact version is pinned by a completion delivery, returns 409; delete the owning session instead so the message/manifest relation is removed atomically. |
GET /artifacts/{ident} |
Raw bytes (see §1). |
POST /uploads |
Base64 JSON upload — not multipart. Body {filename?, content_base64 (or content, or content_text), frame_id?, project_id?}. Supply exactly one content field; two is a 400, because which one is authoritative cannot be guessed. content_base64/content are strict base64 — whitespace is stripped (line wrapping is transport formatting) and anything else outside the alphabet is a 400. content_text uploads text as UTF-8. A rejected upload writes nothing. This used to decode without validate=True, silently discarding stray characters so a corrupted payload decoded to different bytes with no error, and to fall back to storing the raw string's UTF-8 bytes — so a .npy that lost one character became an artifact containing base64 text, versioned and checksummed. File lands in the session workspace (or data_dir/uploads without frame_id), is registered as a versioned artifact (is_user_upload), re-upload of the same name in the same frame creates a new version → {"artifact_id","id","filename"}. |
Customize skill failures are real failures. POST /skills,
PUT|PATCH /skills/{name}, GET /skills/{name}, DELETE /skills/{name} and
POST /skills/import used to answer 200 with {"error": …} in the body. The
service returns soft dictionaries by design (server/skills.py) and still
does; what changed is that the gateway now projects them to a status. Each
failure carries a stable code — the status is derived from the code, never
from the message:
code |
Status | When |
|---|---|---|
skill_name_required |
400 |
empty name, including an import whose frontmatter has none |
skill_name_unsafe |
400 |
the name resolves outside the user skills directory (symlink or traversal). Not 403: nothing was denied by policy, the name is unusable |
skill_name_conflict |
409 |
collides with a bundled skill, which discovery would shadow anyway |
skill_not_found |
404 |
no such user skill |
skill_read_only |
403 |
a bundled skill cannot be edited or deleted. Not 404 — it plainly exists, and saying otherwise is a lie the user can disprove |
skill_no_version_history |
404 |
version history requested for a skill with none |
skill_version_storage_unavailable |
503 |
the version store is absent — a missing dependency, not a bad request |
skill_write_failed |
500 |
the write itself failed (OSError, permissions) |
skill_capability_invalid |
400 |
import only: capabilities.network.mode is outside the closed set. The document parsed, the declared mode is unusable, nothing was written. The strictness is the import boundary's — the tolerant reader still degrades an already-stored Skill's unknown mode to unknown |
Why it mattered beyond tidiness: api() in the web client throws only on a
non-2xx, and the Customize editor's save handler does not inspect the body — so
a rejected save closed the modal and told the user "saved" while nothing was
written. These bodies also never reached public_failure, so they carried no
request_id.
The four sibling routes that already answered a real status —
GET /skills/{name}/versions and POST /skills/{name}/rollback, plus their
/projects/{pid}/… twins — keep the statuses they had (404 and 409
respectively, chosen per route) and now carry the specific code too. Their
statuses are deliberately not re-derived from the code table: that would
change published behaviour for no stated benefit. Branch on code.
| Method & path | Behavior |
|---|---|
GET /skills/catalog |
{"skills":[{…,enabled}…]}. |
PUT|PATCH /skills/catalog/{name}/enabled |
Body {enabled} → {"ok":true}. Skill enablement is persisted through scoped capability state and is enforced by discovery/prompt/runtime loading. |
POST /skills |
Create a Web-authored user Skill under <data_dir>/user-skills: {name,description?,body|content}. Bundled-name collisions and unsafe paths are rejected. |
POST /skills/import |
Accepts a raw SKILL.md in content (frontmatter parsed) or explicit {name, description, body}. The pasted frontmatter is kept, not normalized: only name, description and origin are rewritten — origin is always user, so imported frontmatter cannot claim bundled trust — and requirements, capabilities, license, category, comments and unknown nested keys survive verbatim. capabilities.network is validated strictly on this path: a mode outside the closed set answers 400 skill_capability_invalid and writes nothing (no version row, no file). The 200 carries review — {requirements, capabilities, readiness, warnings}, a safe summary of what the document declares, never an authorization. |
GET|PUT|PATCH|DELETE /skills/{name} |
Read / update / delete a user Skill (URL-encoded name). Bundled openai4s Skills remain non-editable/non-deletable. |
GET /skills/{name}/versions |
Personal immutable version/event history plus safe active manifest; never returns stored source bytes. |
POST /skills/{name}/rollback |
Body {version_id} atomically activates a retained personal version. |
GET /projects/{project_id}/skills/catalog |
Project-owned Skill overlays only; personal fallbacks and bundled entries are omitted. |
GET /projects/{project_id}/skills/{name}/versions |
Exact project-scoped immutable history. Unknown projects fail closed. |
POST /projects/{project_id}/skills/{name}/rollback |
Body {version_id} activates a retained version in that project only. In team mode a member may reactivate a recipe-only Web-authored version; versions containing kernel.py and un-attributed legacy Host versions require an administrator and otherwise return the route's established 409 envelope with code: "skill_admin_required". |
GET /agents |
Bare array of built-in agent descriptors (with enabled). |
PUT|PATCH /agents/{name}/enabled |
{"ok":true}. This legacy built-in-agent roster toggle remains process-local; persisted Specialist capability policy is enforced in delegation separately. |
GET /agents/{name} |
Agent descriptor or 404 {"error":"unknown agent"}. |
GET /specialists |
{"builtin":[…],"specialists":[…]}. |
POST /specialists |
Upsert by name (400 when missing) → agent row. |
GET|PUT|PATCH|DELETE /specialists/{name} |
CRUD; GET 404s with {"error":"not found"}. |
GET /connectors |
{"connectors":[…]} (MCP servers). |
POST /connectors |
{name,command} required (400) → connector row. |
GET /connectors/directory |
{"directory":[…]} — the curated install list. In-tree Python entries use the portable @openai4s/python command token; it is resolved to the current daemon interpreter only at spawn time, and matching legacy absolute-path rows are migrated on startup. |
PUT|PATCH /connectors/{id} |
Edit generic connector metadata and launch configuration. Body fields are optional: {name,description,command,args,enabled,env_updates,remove_env}. Existing environment values are never returned to the browser; omitted names are retained, env_updates explicitly replaces selected values through SecretBroker, and remove_env explicitly deletes selected names. The cached process is disconnected after a successful edit so the next call lazily starts the new configuration. DataPro is managed and rejects this route. |
PUT|PATCH /connectors/{id}/enabled |
{"ok":true}. |
POST /connectors/{id}/probe |
Spawns the server, lists tools; unknown id → 404. |
POST /connectors/{id}/call |
Body {tool,args} → tool result; a failing call answers 502 with code: "connector_failed" (the MCP server's own message is not echoed — it quotes the argv and env it was launched with). |
DELETE /connectors/{id} |
Disconnect + delete → {"ok":true}. |
Doubao Search is the primary managed web-search product in Customize → Network. It shares the brokered Agent Plan credential with Ark and DataPro, but its dedicated test route is deliberately not the generic multi-engine search path: a Tavily or keyless result must never make Doubao appear healthy.
| Method + path | Response / semantics |
|---|---|
GET /doubao-search/config |
Returns {key_configured,ark_key_reused,provider:"doubao-search",primary:true}; credential state is boolean and the provider label is non-secret metadata. It never returns the key, an Authorization value, or the fixed upstream endpoint. Configuration is not an authentication verdict. |
POST /doubao-search/config |
Body {agent_plan_key}. Stores the same SecretBroker credential used by DataPro and mirrors it to the active Ark credential/profile when Ark is selected. The response contains booleans and product metadata only. |
POST /doubao-search/search |
Body {query,num_results?} (query 1–100 characters; num_results 1–50). Calls Doubao Search directly with no fallback and returns normalized {query,count,results:[{title,url,snippet}],source:"doubao",available,message}. available:true requires the real response source to be Doubao and at least one usable result with a non-empty URL; an empty response remains unavailable. Reflected credential text is redacted before projection. |
DataPro is the one managed Streamable HTTP connector and has a narrower
product route. It is not editable or deletable through the generic connector
API, and its generic probe is refused because initialize plus tools/list
does not establish authentication:
| Method + path | Response / semantics |
|---|---|
GET /datapro/config |
Credential, connector, and bundled-Skill booleans only. No key, broker reference, endpoint header, or header value is returned. An active Ark model key is reported as reused only while the active provider is Ark. |
POST|PUT|PATCH /datapro/config |
Body {agent_plan_key}. Stores it through SecretBroker, enables the connector, drops the cached MCP session when the effective credential changes, and mirrors it to the active Ark credential/profile when Ark is selected. The response contains booleans only. |
POST /datapro/search |
Body {query,frame_id?}. Makes a real dataPro_search({query}) tool call through the managed connector, fully indexes the redacted result envelope returned by that call (including structuredContent, content blocks, text, and future fields), saves a JSON Artifact, and returns {structuredContent,content,is_error,code,available,message,index,artifact}. A successful index receipt includes complete:true, logical entry_count, equal source_leaf_count/indexed_leaf_count, and completeness digests. Only a real integer structuredContent.code equal to 0 and a complete index transaction let the UI report “专业数据集可用”; 4011 maps to Key 无效、额度不足,或者专业数据集 Harness 未开启。. Reflected credential text is redacted before the response, index, or Artifact is written. |
The completeness receipt covers all redacted content in the result envelope
returned by this specific DataPro call, including content blocks, text,
unrecognized future keys, and nested values; it
does not claim that one query has enumerated or indexed DataPro's entire remote
corpus. The same ingestion boundary is used by the dedicated product route,
the managed connector call, and host.mcp.call from the bundled Skill. A
failed or non-integer/nonzero tool code creates no successful index batch, and
an index transaction failure prevents an availability success from being
projected.
shares)Read-only session sharing over an outbound relay tunnel. The full protocol,
trust model, and operator controls are in webshare.md; this is
the route index so the surface is discoverable from one place.
| Method & path | Behavior |
|---|---|
GET /shares |
List this daemon's read-only session shares (shares.list_all()). |
POST /frames/{id}/shares |
Create a share for a session (a frames-family route). 403 when sharing is disabled or the relay is unconfigured. |
PUT /shares/{id} |
Publish or update a share (optional TTL); ensures the tunnel. Unknown id → 404. |
DELETE /shares/{id} |
Revoke a share and unregister it from the relay (shares.revoke()). |
| Method & path | Behavior |
|---|---|
GET /compute/gpu |
Local GPU detection report. |
GET /compute/ssh-aliases |
{"aliases":[…]} from ~/.ssh/config. |
GET /compute/remote |
Registered remote-host info. |
POST /compute/remote |
Body {alias,label?}; alias must exist in ~/.ssh/config (400 otherwise); probes GPUs over SSH → {"ok":true,"alias",…,"info"}. |
DELETE /compute/remote/{alias} |
{"ok":bool}. |
GET /compute/providers |
{"providers":[…]}. |
GET /compute/local/hostinfo |
Host info snapshot. |
GET /compute/jobs |
{"jobs":[…]}. |
POST /compute/jobs |
Body {command|code,kind("bash"),cwd?,deadline_s?} → job row. deadline_s defaults to one hour and is refused above 24 h with job_bad_deadline; there is no unbounded run. Local code-exec endpoint — protected only by the Origin check + loopback bind. |
POST /compute/jobs/{id}/cancel |
Cancel result. |
GET /compute/jobs/{id} |
Job row, plus output. |
GET /frames/{fid}/compute/tasks |
This session's remote compute work (owner-scoped; another session's jobs are neither listed nor counted). Opening it does not contact a provider. |
POST /frames/{fid}/compute/tasks/{job_id}/refresh |
Contacts the remote for ONE job because a person asked; the probe is also the harvest. |
POST /frames/{fid}/compute/tasks/{job_id}/cancel |
Body must be {"confirm": true} (400 confirmation_required otherwise, with zero remote calls). 200 {"outcome":"cancel_confirmed","task"} only when the provider confirmed the stop; 202 {"outcome":"cancel_indeterminate","reason","hint","task"} when it could not (remote_unreachable / receipt_unconfirmed / provider_cancel_unsupported) — the job stays live and may still be running and billing; 409 already_terminal when the job reached a terminal state first (the real state rides on task.status); 404 not_found for a job this session does not own. |
A job row carries status from queued|running|done|failed|cancelled|timeout|abandoned.
The last two are distinct on purpose: timeout is the daemon stopping a job that
outlived its deadline, and abandoned is a job the previous daemon was running
when it died — read from its receipt on the next boot, never revived, and never
reported as failed (which would blame the job's own command) or cancelled
(which would claim somebody meant to stop it).
Output is bounded in bytes as it is read, not trimmed afterwards, so every row
carries seen_bytes, retained_bytes, dropped_bytes and truncated. What is
kept is the tail; output is prefixed with a notice when anything was dropped.
| GET /environments/status | {"environments":[{language,status,python_version,package_count,packages,preinstall}],"standard_profile_readiness":{…}}. The additive readiness object is always present. Flag off returns schema_version:1, enabled:false, profile:"standard", state:"unavailable", ready:false, reason:"feature_disabled", checked_locally:false, network_contacted:false, mutation_performed:false, required_environments:["python","r"], empty missing/environment rows, null digest/remediation, and performs no discovery. Flag on returns the path-free local projection: state (ready|needs_setup|needs_repair|unavailable), reason, requirement digest, required/missing environments, missing_packages, per-environment {name,state,present,required_package_count,installed_required_package_count,missing_packages,issue}, and explicit managed plan/apply remediation commands when repairable. It never contacts the network or mutates an environment. |
| GET /environments | Same shape as GET /frames/{fid}/environments, without a session. |
| GET /kernel/packages | {"packages":[…],"preinstall":{…}}. |
| GET /kernel/environment | Full env freeze for Provenance → Environment. |
| POST /kernel/install | Body {packages} or {package} → install report (no kernel restart). Admin only in team mode because it mutates the shared runtime environment. |
| Method & path | Behavior |
|---|---|
GET /memory/enabled |
{"enabled":bool,"override":null}. |
PUT|PATCH|POST /memory/enabled |
Body {enabled} → {"enabled"}. |
GET /memory?project_id= |
{"enabled","memories":[…]} (project_id defaults to all). |
POST /memory |
Body {content,block?("general"),project_id?} → memory row. |
GET /memory/categories?project_id= |
{"categories":[…]}. |
GET /memory/context?project_id= |
{"context":"- …\n- …"}. |
PATCH /memory/{id}?project_id= |
Body {content?,block?} → the edited row. project_id is required and is not defaulted, exactly as for the DELETE: an id is not authority over a project, and a cross-scope edit answers 404 rather than succeeding. Refuses empty or over-long content before the write (memory_empty, memory_too_long), and refuses a request that changes nothing (memory_no_change). Sets updated_at, which is what retention measures — an edit is a touch, so a corrected memory does not expire on the clock of the one it replaced. |
DELETE /memory/{id} |
{"ok":true}. |
POST /memory refuses on two distinct codes when a scope is full.
memory_scope_full counts memories still inside the retention window;
memory_scope_full_expired is the ceiling on rows stored, live or not. They
are separate because the remedies are: one asks the user to delete a memory they
are still using, the other rows that are not being injected at all.
| GET|PUT|PATCH|POST /network/status | Write toggles OPENAI4S_ALLOW_NETWORK (process env + setting); always returns {"enabled":bool}. |
| GET /preferences/builtin-allowlist | {"enabled","egress_mode","granted":[domains],"groups"}. |
| GET|PUT|PATCH|POST /search/config | Backup Tavily key config; write accepts {api_key} or {clear_api_key}; always returns {"endpoint":"https://api.tavily.com/search","api_key_configured":bool} — the key itself is never echoed. The dedicated Doubao Search route never falls back to Tavily. |
| GET /telemetry/consent | {"enabled":bool,"env_locked":bool}. Opt-in anonymous telemetry, off by default; env_locked is true when OPENAI4S_TELEMETRY vetoes it, so the UI can disable a toggle that would otherwise do nothing. |
| PUT|PATCH|POST /telemetry/consent | Body {enabled}. Granting records consent and mints the anonymous install id; revoking deletes both. Neither ever transmits — see openai4s/telemetry/. Returns the same shape as the GET. |
/api/v1/ws)Standard RFC-6455 upgrade (hand-rolled: Sec-WebSocket-Accept computed, no
extensions/subprotocols). Messages both ways are JSON text frames. Protocol
ping frames (opcode 0x9) are answered with pong frames; a JSON
{"type":"ping"} is answered with {"type":"pong"} (the frontend sends the
JSON form every 25 s).
| Message | Effect |
|---|---|
{"type":"ping"} |
→ {"type":"pong"}. |
{"type":"view_session","root_frame_id":fid} |
Subscribes this connection to fid's events. If a turn is in flight, the buffered current-turn events are replayed (replay_begin … events … replay_end); any pending await_permission prompts are re-sent from durable storage even when no session runtime has been rebuilt after restart. frame_id is accepted as an alias. |
{"type":"unview_session","root_frame_id":fid} |
Unsubscribes. |
{"type":"cancel_execution","root_frame_id":fid,"execution_id", "owner":kind,"owner_id":id} |
Requests exact-ticket cancellation and receives execution_cancel_result. cancel is accepted as a compatibility type, but missing/stale/mismatched identity fails closed. |
Events are only delivered to connections subscribed to the event's
root_frame_id (broadcasts with root_frame_id=None go to everyone, but the
gateway does not currently emit any).
Every event has type and (via the hub emitter) a root_frame_id; most also
carry a redundant frame_id. The frontend keys off m.root_frame_id ||
m.frame_id.
For a Stage 1 trusted, Artifact-bearing completion, the final text event also
carries delivery_id. Its assistant message and verified version manifest are
already durable when that event is sent. If socket delivery is lost, reopening
reads the committed message whose links still name exact versions. A
committed ledger row and stable id remain queryable for explicit/future
reconciliation, but the Stage 1 delivery ledger does not drive automatic
re-emission or ask the client to deduplicate such a replay. The ordinary
bounded WS sequence buffer may still replay the event while its turn is live;
after terminal/restart, REST reopen is authoritative. Ordinary prose/tool
chunks and flag-off completion chunks omit delivery_id.
Event type |
Fields (beyond root_frame_id) |
Meaning |
|---|---|---|
notebook_cell_draft |
frame_id, draft_id, revision, source, status, reason |
A Notebook cell the agent is composing, before it runs. Superseded revisions are collapsed in the resume buffer so a reconnect renders only the newest. Emitted by server/agent_run.py. |
recovery_state |
branch_id, recovery_id, state, status, message |
A kernel-recovery attempt changing state. Emitted by server/recovery_execution.py. |
recovery_log |
branch_id, recovery_id, plus the journal entry's own fields |
One line of a recovery's journal, as it happens. Emitted by server/recovery_control.py. |
branch_activated |
branch_id, checkpoint_id, ok |
A branch became the session's active one, and its runtime state was reconstructed. Emitted by server/session_domain.py. |
cursor_checkpoint_failed |
branch_id, source_kind, source_id, reason, ok: false |
A cell or message completed but its cursor checkpoint could not be captured — so forking from that point will 409 rather than reconstruct state it does not have. Emitted by server/session_domain.py. |
delegation_child_event |
event, at, child (a browser-safe projection: identity, lifecycle status, machine-readable task_status, progress, steering counters, and the bounded override summary — never result/output bodies or steering text), plus per-event extras |
A sub-agent started, progressed, or finished. Emitted by agent/delegation.py; the Web wiring (gateway._wire_delegation) routes it through workbench_state.delegation_event_projection, which owns the output exclusion server-side. Carries no frame_id of its own; the hub's emitter attaches root_frame_id. |
replay_begin / replay_end |
— | Bracket the buffered-event replay after view_session mid-turn. replay_begin carries from_seq, to_seq, the daemon run's epoch, and gap. |
text_reset |
frame_id |
Start of a fresh streamed assistant message (clears the live bubble). |
text_chunk |
frame_id, block_type ("text" for prose, "tool" for code-cell echo/stdout/errors), chunk; a code-cell start also carries cell_index, canonical kernel_id, and language; every Stage 4 gated prose chunk also carries provisional: true, review_status: "candidate", turn_id, and execution_id; the stopped marker that ends a cancelled turn is a "text" chunk that also carries cancelled: {request_id, execution_id, reason} |
Incremental stream. The frontend uses the start metadata directly so live Notebook grouping matches the persisted execution log without a status-cache race. A gated chunk remains visibly provisional until exact durable resolution. A chunk carrying cancelled is the same marker GET /frames/{fid}/messages reopens with, so a client can render it as a stopped marker rather than prose; the completion text of a turn that already streamed prose starts with a paragraph break (\n\n), because clients join consecutive text chunks into one markdown block. |
candidate_resolved |
frame_id, message_id, turn_id, execution_id, review_status, user_truth, durable, delivered, replaced, answer_repaired, delivery_id?, text? |
Stage 4 promotion, emitted only after exact message/delivery promotion and terminal persistence. A successful resolution carries canonical text and replaced:true so the frontend reconciles incremental live chunks to the exact reviewed row; answer_repaired says whether Stage 5 changed the answer. delivered:false or durable:false cannot replace prose or upgrade its badge. |
notebook_cell_start |
frame_id, producing_cell_id, cell_index, state_revision, generation_id, kernel_id, language, origin, source, status |
Starts/upserts one immutable Cell identity using the exact attempt-bound runtime generation. |
notebook_cell_chunk |
frame_id, producing_cell_id, stream, chunk |
Appends output to that exact live Cell. Unknown/replayed fields are tolerated. |
notebook_cell_finished |
start identity (including the unchanged state_revision and generation_id) plus complete source/output/error, figures/files and usage |
Replaces the live projection with the authoritative finished revision. |
step |
frame_id, step_id, kind, title, input, status:"running" |
A semantic step began (host call, artifact save, …). |
step_update |
frame_id, step_id, status, output, summary |
Step finished/patched. status is done or error; delegate steps may also end warning (task not done but not broken: partial/blocked/stopped/max_turns/no_progress — green done is reserved for a child whose task_status is completed, and a fan-out takes the worst child's status). A delegate step's output is the structured projection {name, child_id, frame_id, task_status, stop_reason, turns, max_turns, environment, summary, limitations, artifacts, children?, raw} with the bounded raw string reserved for the details reveal; its summary is the task_status word, never a hardcoded "done". Child steps forwarded from a delegated agent carry the child identity under input.delegation and persist root-keyed. Artifact-save steps emit step+step_update back-to-back. |
plan_ready |
frame_id, plan_id, status, plan, artifact_id |
A plan-mode turn produced a structured plan. |
plan_not_captured |
frame_id, request_id, reason (no_plan_steps) |
A plan-mode turn completed but its reply yielded no plan steps (neither a json plan block nor a numbered/bulleted list), so no plan row was stored and there is nothing to approve. Emitted before the turn's terminal frame_update; the turn result carries plan_captured:false. |
plan_progress |
frame_id, plan_id, step_id, status, note |
A plan step ticked during auto-execution. |
await_permission |
frame_id, decision_id, tool, kind, title, input, target, suggested_patterns, scopes, sub_agent |
A tool call is blocked awaiting user approval (answer via POST /api/v1/frames/{fid}/decision). Emitted from openai4s/permissions.py. |
permission_resolved |
frame_id, decision_id, allow, scope, and after restart: resolution_context, requires_continue, original_action_executed, continuation_expires_at, continuation_authorization |
The pending prompt was answered / timed out. An after-restart event explicitly says the old operation did not execute and whether the user must start a fresh continuation. |
frame_update |
frame_id, status, request_id, code + output_committed? (terminal turn events), cancelled (only on a turn's cancelled terminal: the stopped marker's {request_id, execution_id, reason}), task_summary (only with status:"titled") |
Turn/session lifecycle. Emitted statuses: processing, completed, failed, cancelled, success (a finished REPL cell; an interrupted or cancelled REPL cell ends cancelled instead), updated (rename/PATCH), and titled — the background auto-title thread's upgrade of the placeholder session title, which carries an extra task_summary field (the new title) that no other status has. Every turn event — processing, the single terminal form, and the turn's text_reset/text_chunk — also carries execution_id, and that is the field a client filters on. A request id cannot separate two turns: clients may reuse X-Request-Id, and the ordering that matters (processing(A), processing(B), failed(A) — A unwinding after B was promoted out of the queue) then looks like B's own terminal event. A terminal whose execution_id differs from the running turn's must not close it. When one side names no execution the pair falls back to request_id, and when neither names anything the event is treated as current: that is the pre-identity contract, and anything stricter strands every turn against an older daemon. The processing event carries request_id too, and it is the one a queued follow-up depends on: that turn's 202 resolved while an earlier turn still owned the screen, so processing — "your turn is running now" — is the first moment its id is current. Under an HTTP job it is the same string the 202 returned; a direct call (CLI, recovery replay) mints one rather than emitting an empty field. A terminal turn event also carries request_id — the same id the submit 202 returned — and, when the turn failed, a stable code (max_turns for turn-limit exhaustion; no_progress when the progress circuit tripped — the Engine forced completion=None, so the turn is failed rather than left at the completed default, and the streamed prose reads "Stopped repeating actions. Edit the prompt or continue explicitly."; llm_request_burst, llm_rate_limited, or llm_upstream_overloaded for controlled LLM capacity failures; otherwise the projector's, defaulting to turn_failed). These local codes never expose the provider's raw error code or message. output_committed:true is added only when the failure happened after bytes were streamed or a tool ran: llm/models.py calls it the retry veto, because a transparent retry there duplicates visible output or re-fires a side effect however retryable the status looks. It is never emitted as false — absent is "no claim", and a false would assert a safety the projector cannot know. The frontend treats completed|failed|cancelled|success|done as terminal. A gated turn sends exactly one terminal frame event, after candidate_resolved, and includes review_status plus user_truth; the durable stored frame status remains done for a completed turn. |
kernel_status |
frame_id, status ∈ restarted|stopped|started|env_changed|packages_installed|ended, plus per-status extras (generation, env, installed, ok, state, ended_reason, requires_kernel_recovery) |
Kernel lifecycle changes. A successful branch revert emits ended after invalidating both language slots. |
execution_state |
frame_id, execution_id, owner:{kind,id}, status (queued|running|finalizing|completed|failed|cancelled), queue_position, reason |
One exact ticket changed state. |
execution_queue |
authoritative snapshot fields from GET /frames/{fid}/execution |
Queue/position projection; also sent immediately after view_session. |
execution_owner |
execution_id, owner, previous identity, reason |
Active writer changed. |
execution_cancel_result |
scoped cancellation result | Direct reply to a WS cancellation request. In team mode a cancellation aimed at a session the caller may not see answers ok:false with reason:"session not found" — the same sentence an unknown session gets, because which sessions exist is itself protected. |
view_denied |
frame_id, reason |
Team mode only: view_session named a session this login may not see (another member's, or one with no ownership row). Refused before subscription, so neither the replay buffer, pending await_permission prompts, nor the queue snapshot leak. The reason is always "session not found". |
checkpoint_created |
branch_id, checkpoint_id, reason |
An immutable checkpoint committed. |
branch_created |
branch_id, from_checkpoint_id |
A checkpoint-backed branch committed. |
branch_revert_conflict |
branch_id, operation_id, target_checkpoint_id, reason |
Revert was recorded but not applied because the conflict check failed. |
branch_reverted |
branch_id, operation_id, target_checkpoint_id, checkpoint_id, undo_checkpoint_id, ok, requires_kernel_recovery |
Revert committed append-only state; clients must refresh branch/recovery projections. Full previews/checkpoint records stay in the direct REST result and never enter WebSocket. |
branch_projection_restored |
frame_id, branch_id, checkpoint_id |
The branch-scoped projection was rebuilt (for example after a Revert); clients holding a stale message/Notebook view must refetch it rather than patch. |
branch_activation_state |
frame_id, root_frame_id, branch_id, checkpoint_id, status/state |
Activation of a branch runtime progressed. status and state carry the same value — a compatibility duplication kept because both spellings are already consumed. |
artifact_created |
non-uniform — see below | An artifact was produced, edited, renamed, uploaded, restored, or deleted. |
artifact_ref_problems |
frame_id, problems[] of {ref, code, message} (max 8) |
One or more @file references in the user's message did not resolve. Emitted rather than raised: a user who referenced four files and mistyped one wants an answer about the other three plus a note, not a refusal. Codes: not_found (absent, or in another project — deliberately the same answer), no_frozen_bytes, not_text (a binary artifact, which used to be pasted in as replacement characters), cross_session_not_allowed, materialise_failed, too_many_refs. The previous behaviour was to drop an unresolvable reference silently, so the user asked about a file the model never received. |
attachment_problems |
frame_id, problems[] of {name, reason, limit?, bytes?} (max 8) |
Pinned figures that exceeded this turn's image budget and were not sent. Reasons: too_many (more than 8 images), too_large (one image over 4 MiB after the pin markers are drawn), budget_exhausted (12 MiB total). None of these limits existed: every pinned figure was attached at full size, so eight pins on a large raster sent ~10 MiB and eighty sent ten times that. The model is told as well as the user — a system note names the missing figures and instructs it to say they were not received, rather than describe a picture it never got. |
pong |
— | Reply to JSON ping. |
artifact_created payload non-uniformity (wart, load-bearing)The gateway emits four different shapes under the same event type:
{"type":"artifact_created","artifact":{"id","artifact_id","version_id",
"filename","content_type","size_bytes","project_id","root_frame_id"}}.
Note the duplicated id/artifact_id.artifact object: edit has
{id,filename,version_id,root_frame_id}; rename has
{id,filename,root_frame_id} (no version_id); upload has
{id,filename,content_type,root_frame_id} (no version_id).plan_*.json) — a flat event with no nested
artifact key at all: {"type":"artifact_created","frame_id",
"artifact_id","filename"}.{"type":"artifact_created","root_frame_id"} with no artifact info
whatsoever.The event can also be absent entirely: the edit/rename/upload/delete/
restore broadcasts only fire when the artifact has a root_frame_id (for
uploads, only when frame_id was supplied in the request) — an upload
without frame_id stores the file but emits no artifact_created at all.
Consumers must treat every field as optional. The frontend does exactly this
(const art = m.artifact || {}; const aid = art.id || art.artifact_id;):
when version_id is present it is used as an image-cache-bust key, otherwise
the event just triggers an artifact-list reload. Do not rely on
artifact_created.artifact.id being present or stable across emit sites.
Defined at module level in gateway.py so tests can import them. All
timestamps are ISO-8601 strings (or null).
_frame_json): {id, root_frame_id, parent_frame_id, project_id,
name, task_summary, model, status, folder_id,
conversation_type:"agent", message_count, input_tokens, output_tokens,
created_at, updated_at}. List rows additionally get running and
kernel_alive._project_json): {project_id, id, name, description, context,
conversation_count, last_active_at, created_at, updated_at, is_example}
(project_id/id duplicated)._artifact_json): {id, artifact_id, filename, content_type,
size_bytes, version_id (= latest version, the UI cache-bust key),
checksum, project_id, root_frame_id, priority, created_at,
is_user_upload} (id/artifact_id duplicated)._note_json): {note_id, id, content, created_at, updated_at}._annotation_json): {id, annotation_id, root_frame_id,
artifact_id, artifact_name, x, y (0–1 fractions), number, body,
status("open"|"sent"), version_id, created_at, updated_at}. version_id is
the artifact version the pin was taken against (null on pins created before
the binding existed, which fall back to the artifact's latest version).The duplicated-key pattern (id + a typed id) is deliberate frontend
compatibility; keep both when touching these serializers.
GET /api/v1/projects pages by opaque cursor (limit 1–100) and, for one
compatibility window, honours offset. An unparameterized request still
returns the full list. Real bounded reads also exist for from/limit on
messages, limit on frames, and the Timeline's before_ordinal/after_ordinallimit windows (§2).artifact_created has four payload shapes; every field is optional (§3).400 rather than decoded to other bytes or stored as text
(§2).{error}, others return {} (frame/project GET), {"ok":true}
(idempotent deletes), or a nulls-filled 200 (/artifacts/{aid}/lineage).400 malformed_json./artifacts/{aid}/lineage for the Provenance UI. Session packages, share
snapshots, and Artifact ZIPs still do not carry a portable observation
ledger; do not infer portable observation provenance from an Artifact version
or a client-side metadata export alone./compute/jobs is still a privileged surface.notebook_cell_* events are live projections; reconnect safety
still relies on the compatibility text_chunk stream and authoritative
/execution-log reload rather than a durable per-Cell WS backlog.