State machine
PAUSED is in the enum and on the roadmap.
Race safety
All three operator-side transitions (accept, start, end) and
consumer cancel use CAS (compare-and-swap) updates — the SQL
includes a WHERE state=... clause so only one writer wins. The loser
sees a fresh fetch of the actual current state, surfaced as
INVALID_STATE with the conflicting state in detail:
INVALID_STATE — the
detail tells you what happened.
Creating a session
REQUESTED state. The rate is computed
and stamped (immutable for the rest of the session). The hold =
rate × maxDurationSeconds is calculated for client-side display.
maxDurationSeconds bounds the LIVE meter (worst-case settle hold).
waitTimeoutSeconds is independent — it bounds how long the session
sits in REQUESTED/ASSIGNED before the cron flips it to EXPIRED.
Pass it when you want the consumer to wait longer (or shorter) than
the default 5 minutes. Server clamps to [5, 3600].
Before accepting the request, the API reads
LuxxonSettlement.deposits(consumerWallet) on-chain and rejects
with INSUFFICIENT_CREDIT (HTTP 400) if the pool balance can’t
cover the worst-case meter. Top up via
LuxxonSettlement.deposit(amount) and retry — the session will
clear immediately once the chain reflects the new balance.
Pass quoteId to lock the rate via the quote-locked flow — see
Pricing.
Accept (operator side)
REQUESTED → ASSIGNED. The operator workspace’s id gets
stamped on operator_workspace_id. There is no signature step —
the consumer’s pool already covers the worst-case meter, and the
on-chain contract enforces the meter invariant at settle time, so
no per-session authorization payload needs to flow back to the
consumer. The operator can /start immediately.
There’s no SpiceDB write at accept time; session visibility is a
direct FK match against consumer_workspace_id /
operator_workspace_id.
The workspace must have SUPPLIER in roles, else
session:notSupplier (403).
Start (operator side)
live_input, returns the WHIP +
WHEP URLs, flips the operator workspace to BUSY. State stays
ASSIGNED — the transition to LIVE only fires once the
server-side WHEP subscriber decodes a frame off the wire. That
means consumers polling for state === "LIVE" won’t see it until
get_frame will actually return bytes — no more 3-5s “LIVE but
404 on /frame” race.
startedAt is stamped at the LIVE transition (not at /start), so
the meter only charges from media-flowing time. WebRTC negotiation
- first-keyframe latency is free.
start is idempotent: if a live_input was already minted for
this session, the second call returns the existing WHIP/WHEP URLs
instead of creating a new one. Operator can safely re-fetch (page
reload mid-warmup, double-click on the Start button, etc.).
End
sessions:operate OR sessions:create (either side
may end — operator hangs up, or consumer ends the call). Wallet-session
callers pass by FK match against the consumer or operator workspace
on the session.
CAS: LIVE → ENDED. The meter runs:
ENDED, the settlement payload is composable — see
Settlement.
Cancel (pre-LIVE)
REQUESTED or ASSIGNED. Transitions to CANCELLED. No
meter, no charge. Tears down the Cloudflare live_input and
releases the operator workspace (BUSY → ONLINE) in the same
transaction so the workspace can take new work immediately.
DELETE on a LIVE session is not end — it returns INVALID_STATE.
Use POST /:id/end for that.
Operator bulk cancel
When an operator’s device crashes mid-stream they’re left with stuckASSIGNED rows that pin the workspace as BUSY until the consumer
cancels or the pre-LIVE expiry fires. The operator can reclaim the
workspace immediately:
ASSIGNED session where the caller’s workspace is
the operator. Returns { count, cancelled[] }. LIVE sessions are
NOT touched — those still finish via /end so the meter can run.
Scope: sessions:operate.
Pre-LIVE expiry
A background cron sweepsREQUESTED / ASSIGNED rows where
createdAt + waitTimeoutSeconds < NOW() and moves them to
EXPIRED. This is the backstop for an operator that called
/start but never published, or a REQUESTED session that nothing
ever picked up. No meter is run (startedAt is null), but the
operator workspace + Cloudflare live_input are still torn down.
Reading
Both gated by direct FK match against
consumer_workspace_id /
operator_workspace_id. Either side of the session sees it; nothing
else does.
Video plane
POST /sessions/:id/start returns the WebRTC URLs the operator
uses to publish and any consumer uses to subscribe:
whipUrl— the operator POSTs an SDP offer here to publish (WHIP). The URL itself is the auth bearer; treat it as secret.whepUrl— anyone with this URL can subscribe (WHEP). Treat as a session-scoped secret.
GET /sessions/:id/viewer-token→{ whepUrl }(consumer + operator)GET /sessions/:id/producer-token→{ whipUrl }(operator only)
getUserMedia (rear cam preferred), fetches
the WHIP URL via /producer-token, negotiates the publish, and
posts location heartbeats every ~5s while publishing.
Frames for AI agents
GET /sessions/:id/frame returns image/jpeg of the latest
decoded video frame from the operator’s feed. Designed for
agents that poll a single observation rather than consuming a
continuous WebRTC stream — the JPEG goes straight to a vision
model without a media-decoding stack on the caller’s side.
Implementation: lx-api runs a per-session WHEP subscriber that
decodes and caches the latest frame (~1 fps). The session’s
state transition to LIVE is itself gated on the first buffered
JPEG — so the first get_frame call after state === "LIVE"
always returns bytes, with Cache-Control: no-store. Calls during
ASSIGNED (pre-first-frame) return 404 FRAME_NOT_AVAILABLE,
which is also what consumers waiting via the SDK’s waitFor see
until LIVE lands.
Telemetry-driven meter
cleanSeconds is the wall-clock duration minus the sum of
LxDisconnectWindow rows opened during the session.
Disconnect windows have a reason:
NETWORK_ERROR— Cloudflare’s edge stopped seeing mediaSTALE_TELEMETRY— the operator’s location heartbeats stopped advancingOUTSIDE_GEOFENCE— the operator strayed outside the consumer’s requested radius
cleanSeconds directly and passes it as
chargeableSeconds in settleFromPool(). See
Trust model for the discriminator.
Not in v1 yet
PAUSEDstate (consumer pauses mid-flight — drones, bookended outages)- Per-frame
/observationsSKU (priced separately from per-second video) - H.264 server-side frame decode for the agent-frame endpoint
(works today for desktop publishers via VP8; iOS Safari
publishers fall back to H.264, where
GET /framereturnsFRAME_NOT_AVAILABLEuntil we wire a second decode path — browser-to-browser WHEP playback is unaffected)