Skip to content

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.

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:

Terminal window
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.

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:

  • dev profile (default). The daemon runs the none auth backend, which accepts every request and resolves it to the development tenant context. No Authorization header is required — which is why the smoke.sh calls carry no credentials.
  • oauth profile. Protected routes require Authorization: Bearer <jwt> (curl … -H "authorization: Bearer $JWT"); missing, expired, or invalid tokens are rejected. An operator running the api-key backend sends X-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.

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}).

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):

Terminal window
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": "..."}}.

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:

Terminal window
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.

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.

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:

Terminal window
curl -sN "$REST/api/v1/events?session=$SESSION_ID"

Each event is a standard SSE frame:

event: lifecycle
data: {"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 single event: error frame rather than tearing the stream down.

  • The data: body always carries envelopeId, the emitting agentRef ({tenantId, agentUuid}), timestampUnixMs, and the typed payload. The variant’s own fields live inside payload, one level down — and agentRef appears 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 as from=<position> or via the Last-Event-ID request header (curl … -H 'last-event-id: 42'); when both are supplied, Last-Event-ID wins (the browser EventSource reconnect 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.
  • source is a label, not the enum’s ordinal — and so is every closed-enum field on this stream. See below.

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 unknown is 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_RUNNINGrunning. 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 gRPC TailEvents RPC 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 an id: 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.

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:

Terminal window
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:

Terminal window
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"

DELETE /api/v1/sessions/{id} requests graceful subtree cancellation and returns 204 No Content:

Terminal window
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.

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 the x-request-id header every response carries and every log line stamps.
  • REST + SSE — a language without a generated SDK, a shell script, a browser dashboard (EventSource speaks 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.