Sessions & pods

a long-lived, multi-service pod you drive interactively — quota-counted like a run

What a session is

A run executes your code once and ends. A session keeps a pod — one or more services started together — alive so a human can drive it live (teleop a sim, poke a bridge, watch a viewer).

A session is just a Mode=session job carrying the pod, so it is tenanted and quota-counted exactly like a run: it reserves against the policy ledger at submit and releases on stop. keepalive pings + an idle-TTL reaper bound its lifetime, so nothing runs forever by accident.

Live video (WebRTC) is deferred — see the last card. Interactive control today is the bidirectional /data teleop channel.

Launch a pod

POST /v1/sessions (needs runs:submit). A pod is a list of services; each declares an image or a stack, optional needs (start order), and an optional health gate. Services start in topological order — a service waits until every service it needs is healthy.

curl -X POST $BASE/v1/sessions \
  -H "Authorization: Bearer slt_…" -H "content-type: application/json" \
  -d '{
    "name": "teleop-lab",
    "project": "wing-study",
    "idle_ttl_sec": 600,
    "pod": {
      "services": [
        { "name": "sim", "stack": "mujoco",
          "cpu_cores": 4, "ram_gb": 8,
          "health": { "command": ["curl","-sf","localhost:8080/healthz"],
                      "period_sec": 5, "timeout_sec": 2, "retries": 3 } },
        { "name": "bridge", "image": "ghcr.io/acme/teleop-bridge:1.2",
          "needs": ["sim"] }
      ]
    }
  }'

Returns 202 with the session view — note phase: “launching” and the teleop_stream you drive it through:

{
  "id": "ses_9f21…", "job_id": "job_…", "org_id": "org_…",
  "name": "teleop-lab", "project": "wing-study",
  "phase": "launching", "idle_ttl_sec": 600,
  "created_at": "2026-07-15T10:00:00Z",
  "services": [ { "name": "sim", "stack": "mujoco", "image": "…", "needs": [] },
                { "name": "bridge", "image": "…", "needs": ["sim"] } ],
  "teleop_stream": "data"
}

The pod DAG is validated up front — a cycle, an unknown needs, or a service with neither image nor stack is a 400/422 before anything is scheduled. Per-service cpu_cores/ram_gb/gpu_count are summed into the pod’s quota footprint, so an over-cap pod is a 429 at submit (see Policy & quotas).

Lifecycle: keepalive & reap

Poll GET /v1/sessions/{id} until phase is ready (all services healthy). Phases:

launching
the pod job is queued / pulling / starting
ready
every service is up and healthy — drive it now
idle
ready but no keepalive within the TTL — the reaper will stop it next sweep
stopping · stopped · failed
terminal — the pod is torn down and its quota released

A live client must keepalive to hold the pod open. With no ping within idle_ttl_sec (default 300s, or the deploy’s POLYGON_SESSION_IDLE_TTL_SEC), the reaper stops the session and frees its reservation — no leak, no forgotten pod.

# hold the pod open (call well within idle_ttl_sec) — 409 if it is no longer live
curl -X POST $BASE/v1/sessions/ses_9f21…/keepalive -H "Authorization: Bearer slt_…"

# list your org's sessions, newest first
curl $BASE/v1/sessions -H "Authorization: Bearer slt_…"

# stop it yourself (needs runs:cancel) — tears the pod down, releases quota
curl -X DELETE $BASE/v1/sessions/ses_9f21… -H "Authorization: Bearer slt_…"

Drive it: bidirectional teleop

Interactive control rides the run’s realtime /data stream (the session’s teleop_stream). It is two-way: read telemetry over SSE, write control back over the teleop endpoint. Both halves are gated by a short-lived HMAC ticket minted from subscribe after a tenancy check — a ticketless or cross-org send is 401/404, never the bearer directly.

# 1) mint a stream ticket for the session's pod job (60s, single stream)
TICKET=$(curl -sX POST "$BASE/v1/jobs/$JOB/streams/data/subscribe" \
  -H "Authorization: Bearer slt_…" | jq -r .ticket)

# 2) READ telemetry (SSE, ticket in the query — EventSource can't set headers)
curl -N "$BASE/v1/jobs/$JOB/streams/data/data?ticket=$TICKET"

# 3) WRITE a control frame back to the pod (downstream teleop)
curl -X POST "$BASE/v1/jobs/$JOB/streams/data/teleop?ticket=$TICKET" \
  --data '{"cmd":"set_target","x":1.0,"y":0.5}'
# → 202 { "delivered": true, "stream": "data", "seq": 1721037600000000000 }

The node running the pod writes each frame to /streams/data.teleop for a service to consume. A send to a pod that isn’t running yet is a 409.

Live video (WebRTC) is deferred. POST /v1/sessions/{id}/signal is a documented stub that returns 501 — the SFU-lite media relay + coturn are infra-gated and not wired in this environment. The signaling seam (session.signal over the node channel) is in place for when the media plane lands; teleop over /data is the interactive path today.
Sessions are enabled per deployment — a submit against a deploy without the session store returns 503 sessions disabled. All routes are org-scoped: a cross-tenant or unknown id is 404.