REST + SSE
The REST surface is the language-agnostic path into the substrate: start, list,
read, and cancel sessions over plain HTTP JSON, and tail the live event bus as a
Server-Sent Events (SSE) stream. It is a thin, transport-blind adapter over the
same substrate workflows gRPC serves — the transport differs, the behaviour does
not. Every example below mirrors
deploy/local/smoke.sh,
a real REST + SSE consumer.
Connect
Section titled “Connect”In the local stack the REST API listens on 50052 (the gRPC daemon is on
50051). Every route is under /api/v1. The health check is public — no
credentials — so it doubles as a reachability probe:
REST="http://localhost:50052"curl -sSf "$REST/api/v1/health"# {"status":"ok"}Bringing the stack up is covered in Run the local stack.
The served OpenAPI 3.x document at GET /api/v1/openapi.json (public, like
health) is the exhaustive, always-current contract — generated from the handlers
and treated with the same rigor as the .proto is for gRPC. It is the reference
for the exact request/response schemas this page keeps at reader altitude. No
Swagger UI is mounted — the daemon serves the JSON only; point your own viewer or
codegen client at it.
Authenticate
Section titled “Authenticate”Auth is a transport concern handled entirely at the request boundary, before any substrate workflow runs. Which credentials you send depends on the daemon’s active profile:
devprofile (default). The daemon runs thenoneauth backend, which accepts every request and resolves it to the development tenant context. NoAuthorizationheader is required — which is why thesmoke.shcalls carry no credentials.oauthprofile. Protected routes requireAuthorization: Bearer <jwt>(curl … -H "authorization: Bearer $JWT"); missing, expired, or invalid tokens are rejected. An operator running the api-key backend sendsX-Apomesh-Api-Key: <key>instead. Only the health check and the OpenAPI document stay public.
Beyond authentication, each protected route carries a required scope, and a
denial’s shape is deliberate: a denied write returns 403, a denied get-shaped
read returns 404 (indistinguishable from a resource absent in your tenant), and
a denied list read returns an empty collection — none confirm that a resource you
cannot reach exists. An unmapped protected route fails closed (403). The scope
vocabulary and these outcomes are the
identity and authorization plane.
The route families
Section titled “The route families”Every family lives under /api/v1. Treat the OpenAPI document as the exhaustive
endpoint list; at reader altitude the surface is:
- sessions — start (
POST), list (GET, keyset-paginated), read (GET /{id}), cancel (DELETE /{id}). - events — the SSE tail (
GET), detailed below. - published-agents / published-skills / strategy-configs — the registry: each
is
list+publish+get-one-by-version. These back the console’s agent catalog, and a published agent’s(name, version)is what a published start resolves against. - hitl — the human-in-the-loop queue (
GET) and response routing (POST). - providers — the credential / profile / model configuration plane.
- memory — durable memory-space registry, the tiered reads (semantic, episodic, procedural), and per-session working memory.
- tenants / quotas — the per-tenant configuration projection.
- tokens — runtime scoped-token administration.
- audit — audit-log reads. assets — content-addressed blobs
(
GET /{content_hash}).
Start a session
Section titled “Start a session”POST /api/v1/sessions accepts one of two start shapes and returns the
substrate-issued sessionId with 201 Created. The inline start carries a
full rootAgent definition — the smoke.sh body. Watch the casing: the body is
camelCase at the top level (rootAgent, sessionKind, projectId), while the
nested rootAgent is the substrate’s own AgentDefinition and uses snake_case
(capability_tier, memory_scope, supervisor_strategy):
REST="http://localhost:50052"
RESPONSE=$(curl -sS -X POST "$REST/api/v1/sessions" \ -H 'content-type: application/json' \ -d '{ "rootAgent": { "capability_tier": "Worker", "lifecycle": "Stateless", "goal": {"description": "What is the top news story today?"}, "budget": {"cost": {"micros": 2000000, "currency": "Usd"}}, "memory_scope": { "episodic": "ReadWrite", "semantic": "Read", "procedural": "Read", "locality": "CloudAllowed" }, "tools": [ {"Capability": ["WebSearch", "Auto"]}, {"Capability": ["WebFetch", "Auto"]} ], "placement": "Cloud", "supervisor_strategy": "Escalate" }, "sessionKind": "deepResearch", "projectId": "smoke" }')
SESSION_ID=$(echo "$RESPONSE" | jq -r '.sessionId')echo "session: $SESSION_ID"The response body is {"sessionId": "...", "rootAgentRef": {"tenantId": "...", "agentUuid": "..."}}.
Start from a published agent
Section titled “Start from a published agent”The published start launches a pre-configured, version-pinned agent instead
of seeding the root inline. Here rootAgent MUST be absent — the published entry
is the locked shape — and the operational inputs goal + budget ride at the top
level. List available agents with GET /api/v1/published-agents; pass one back as
publishedAgent:
curl -sS -X POST "$REST/api/v1/sessions" \ -H 'content-type: application/json' \ -d '{ "publishedAgent": {"name": "io.descoped/deep-research", "version": "1.0.0"}, "goal": "What is the top news story today?", "budget": {"cost": {"micros": 2000000, "currency": "Usd"}} }'Mixing the two shapes is a typed 400 — an inline start with a top-level goal,
or a published start with a rootAgent, is rejected with a message naming the
conflict.
Selecting the strategy
Section titled “Selecting the strategy”The sessionKind discriminator picks which coordinator strategy the session runs
under — generic, deepResearch, evaluatorOptimizer, parallelSample, or
debate — from the daemon’s strategy registry. On a published start, omit
sessionKind to inherit the strategy the agent’s manifest locked; supplying it is
an explicit override. A start that resolves to no strategy is a typed 400.
Addressable strategy configurations live in the strategy-configs registry
(GET/POST /api/v1/strategy-configs) — a published skeleton plus locked
parameters, addressed by (name, version). Name one directly on the start body to
run the session under it:
{ "rootAgent": { "...": "..." }, "strategyConfig": { "name": "io.descoped/eo-strict", "version": "1.0.0" }}strategyConfig is the highest-precedence strategy source: it outranks both
sessionKind and a catalog manifest’s declared strategy. It resolves under your
own tenant, so a reference you cannot see is the same typed 404 as one that was
never published — never a silent fallback to a default skeleton. A malformed
name or version is a 400 at the request boundary.
On a published-agent start, omit it: a published entry locks its own strategy,
so naming one is a shape override and is rejected 400. Selection there is the
agent’s own lock, chosen at publish time.
Tail events
Section titled “Tail events”GET /api/v1/events?session=<id> returns a text/event-stream. The stream ends
server-side when the session’s root reaches a terminal lifecycle, so a plain
curl -sN exits on its own:
curl -sN "$REST/api/v1/events?session=$SESSION_ID"Each event is a standard SSE frame:
event: lifecycledata: {"envelopeId":"...","agentRef":{"tenantId":"...","agentUuid":"..."},"timestampUnixMs":...,"payload":{...}}id: 42-
The
event:name is the payload variant in snake_case —lifecycle,cost_event,streaming,dispatch,hitl_paused, and the rest of the closed event vocabulary. A bus-side error surfaces as a singleevent: errorframe rather than tearing the stream down. -
The
data:body always carriesenvelopeId, the emittingagentRef({tenantId, agentUuid}),timestampUnixMs, and the typedpayload. The variant’s own fields live insidepayload, one level down — andagentRefappears at both levels, so a reader pointed at the wrong one filters correctly by agent and only fails later, on the first field the envelope does not have. -
The
id:line is the event’s position token (a decimal string). Resume a tail from that point — instead of replaying from the start — by passing it back asfrom=<position>or via theLast-Event-IDrequest header (curl … -H 'last-event-id: 42'); when both are supplied,Last-Event-IDwins (the browserEventSourcereconnect path). It is the same position semantics the event log records.
A cost_event shows the shape, and is the one most consumers reach for:
{ "envelopeId": "…", "agentRef": { "tenantId": "acme", "agentUuid": "…" }, "timestampUnixMs": 1730462400000, "payload": { "tenantId": "acme", "agentRef": { "tenantId": "acme", "agentUuid": "…" }, "source": "llm_call", "provider": "anthropic", "profileId": "anthropic-prod", "model": "claude-haiku-4-5", "promptTokens": 18, "completionTokens": 12, "cachedTokens": 4, "mediaUnits": 0, "promptCostUsd": "0.000123456789", "completionCostUsd": "0.000000000001", "cachedCostUsd": "0.000000000002", "totalCostUsd": "0.000123456790" }}Two things about that payload are contract, not incident:
- The USD components are strings, and decimal. They are passed through from
the wire verbatim rather than parsed, because a float cannot represent them and
nothing downstream could recover the difference. Parse them with a decimal type,
not a
double. sourceis a label, not the enum’s ordinal — and so is every closed-enum field on this stream. See below.
Closed enums arrive as names
Section titled “Closed enums arrive as names”Every enum-typed field on the stream is a lower-case label, never an integer:
phase is "created", exitCondition.kind is "failed", failureClass is
"budget", source is "llm_call". Never compare one to a number — an
ordinal is not part of this contract, and reading one would mean re-encoding the
substrate’s own vocabulary in your client.
The labels are derived from the protobuf definition rather than hand-written, so a variant added to any of these enums arrives named, without a release note telling you to expect it. Two consequences worth designing around:
- The set is open upward. Handle an unrecognised label rather than
exhaustively matching today’s — the value
unknownis reserved for an ordinal your reader’s build has no variant for at all, which is what a newer peer produces. - A label is the variant’s proto name, lower-cased with its enum prefix
removed:
LIFECYCLE_PHASE_RUNNING→running. Renaming a variant is a wire change and would change the label with it. The tail rides the substrate’s one live-subscription primitive — the same one the gRPCTailEventsRPC consumes, so the two transports differ only in framing. That primitive polls the durable log rather than fanning out through a buffer, which is what makes it lossless: a slow consumer makes its own tail slower, never gappy, so there is no dropped-event case to reconcile. A tail opened mid-session backfills what it missed before going live, and resuming from anid:you already handled returns exactly the remainder — no gap, no duplicate.
The optional agent=<uuid> parameter narrows the stream to one agent. Because the
stream is session-scoped and the tenant comes from your authenticated context, a
cross-tenant or cross-session subscription is syntactically impossible.
Read and list sessions
Section titled “Read and list sessions”GET /api/v1/sessions/{id} returns the detailed view — the agent tree, per-agent
budget and lifecycle phase, and, once terminal, the decoded structured
deliverable under terminalOutcome:
curl -sS "$REST/api/v1/sessions/$SESSION_ID" | jq '.rootBudget, .terminalOutcome'GET /api/v1/sessions lists the calling tenant’s sessions with keyset
pagination. Pass ?limit=N; the response is {"items": [...], "nextCursor": "..."}, and you pass the opaque nextCursor back as ?cursor= for the next page
(it is omitted at the end). Keyset — not offset — keeps a page stable as new
sessions land:
CURSOR=$(curl -sS "$REST/api/v1/sessions?limit=20" | jq -r '.nextCursor // empty')[ -n "$CURSOR" ] && curl -sS "$REST/api/v1/sessions?limit=20&cursor=$CURSOR"Cancel a session
Section titled “Cancel a session”DELETE /api/v1/sessions/{id} requests graceful subtree cancellation and returns
204 No Content:
curl -sS -X DELETE "$REST/api/v1/sessions/$SESSION_ID" -o /dev/null -w '%{http_code}\n'A cross-tenant target returns 404 rather than confirming the session exists —
the substrate denies existence-probing across tenants.
Error handling
Section titled “Error handling”Errors are typed and, where they carry substrate-internal detail, redacted:
400— invalid request body or query, with an operator-actionable message (a wrong-path start shape, a malformed cursor, an invalid SSE position token).401— no resolvable tenant context (missing or unusable credentials).403— a denied write scope (fixed body{"error": "permission_denied"}), and the fail-closed response for an unmapped protected route.404— a get-shaped read you lack scope for, a missing resource, or a cross-tenant target — indistinguishable by design.413— a request body over the configured size limit.503— the daemon has no session-strategy registry, or the SSE connection cap is reached.500— an internal error, redacted to a fixed"internal error"; correlate it with thex-request-idheader every response carries and every log line stamps.
When to use REST vs the SDK vs gRPC
Section titled “When to use REST vs the SDK vs gRPC”- REST + SSE — a language without a generated SDK, a shell script, a browser
dashboard (
EventSourcespeaks SSE natively), or any HTTP client. This page. - The Python SDK — a typed client over the same workflows, with the wire shapes modelled as Python types and contracts checked for you. See Python SDK.
- gRPC — the substrate’s native surface, with the full request vocabulary (a few start fields — summarizer tier, per-role config — are gRPC/SDK-only today). Prefer it for high-throughput or strongly-typed backend integrations.
All three delegate to the identical substrate workflow; a session started over REST is indistinguishable from one started over gRPC.
Next steps
Section titled “Next steps”- Python SDK — the same workflows with a typed client.
- Deep Research — the vertical the examples above drive.
- The event log — what the SSE tail is a live projection of.