Live progress
While a worker run builds, the app shows its progress live on a building card: what stage it is at, what the builder says, which file it is editing. Those events travel through the edge Worker (edge/), which keeps one Durable Object per run.
This page is about build progress in the hosted product. Realtime features inside games (leaderboards, presence, rooms) run on each game's own backend and are covered in Backend overview.
The path of an event
Sandbox Server (workflow) Edge Worker App
relay reads progress.jsonl --+ claimRun: status running --+
progress, tool | succeedRun: build, status |
+--- POST /rt/runs/:runId/events --+--> RunChannel Durable Object ---ws---> building card
Bearer crt1 (publish) keeps the last 50 events wss /rt/runs/:runId?token=crt1 (subscribe)
Who publishes what
- The relay in the box.
run.shstartsnode helpers.cjs relay, which reads new lines ofprogress.jsonlabout once a second and posts them to the edge in batches of up to 100 events. It is best effort: a failed post is dropped and never slows the run. It turns lines into events:- a stage from the script ("Fetching your game", "Packaging the game") becomes
progress; - the builder's own text becomes
progress, one line, at most 280 characters; - a tool call becomes
tool, with a player-friendly detail such as "Editing src/game/index.tsx", at most 200 characters; - the final result becomes
progress"Checking the result".
- a stage from the script ("Fetching your game", "Packaging the game") becomes
- The workflow.
status: runningwhen it claims the run, thenbuildandstatus: succeeded, orstatus: failed, at the end. With credits on it also publishes anassetsevent when it asks the player to approve art and sounds. Each batch gets a fresh, short-lived publish token (publishEvents()insrc/lib/realtime.ts, 5 second timeout, never throws). - The 15-second poll.
pollRuncopies the newest progress line intogame_runs.progress. The app shows that when it has no live socket.
Event shapes
From RunEvent in src/lib/realtime.ts:
{ type: "progress", at, text }
{ type: "tool", at, name, detail }
{ type: "status", at, status } // "queued" | "running" | "succeeded" | "failed"
{ type: "build", at, buildId, version, manifestUrl, androidManifestUrl?, webManifestUrl?, draft? }
{ type: "assets", at, quote }
The edge only checks that type is a string. Clients ignore types and fields they do not know.
The edge API
POST /rt/runs/:runId/events Authorization: Bearer <publish token> {"events": [ ... ]}
GET /rt/runs/:runId?token=<subscribe token> (Upgrade: websocket)
GET /rt/health {"ok": true, "realtime": <secret set>}
- Publish: 1 to 100 events per call, each an object whose
typeis a string of at most 64 characters and at most 4 KB as JSON. The answer is{"ok": true, "accepted": n, "subscribers": n}. Errors: 400 (not JSON, noeventsarray, empty, notype), 413 (too many events or too large), 401 (bad token), 503 (REALTIME_SECRETnot set). - Subscribe: the token is checked before the upgrade, so a bad one fails the handshake with 401. The first message is
{"type":"snapshot","events":[...]}with up to the last 50 events, then each new event as its own message. A client replaces its list with every new snapshot, so a reconnect never duplicates events. Sending exactly{"type":"ping"}gets{"type":"pong"}without waking the object. - Cleanup: once a batch ends in
succeededorfailed, an alarm is set 24 hours out (any later event pushes it back). A run that never finishes is cleaned up 7 days after its last event. The alarm closes the sockets with1000 "run expired"and deletes the object's storage. - Run ids must match
[A-Za-z0-9._:-]{1,128}. The object for a run isRUN_CHANNEL.getByName(runId).
Tokens
The server signs tokens with REALTIME_SECRET; the edge only verifies them.
crt1.<payload>.<signature>
payload = base64url(JSON {run, scope, exp, user?}) scope: "publish" | "subscribe" | "llm", exp in unix seconds
signature = base64url(HMAC-SHA256(key = REALTIME_SECRET, message = <payload> as it appears in the token))
base64url has no padding. The edge accepts a token when the signature is valid (checked in constant time), scope matches the route, run equals the run id in the path and exp is in the future.
| Token | Minted by | Lifetime |
|---|---|---|
| subscribe | subscribeUrl(), returned as run.live.url in every active run the app API hands out | 15 minutes |
| publish, server side | publishEvents(), per batch | 5 minutes |
| publish, the box's relay | startRun | 75 minutes (the run's budget plus setup and upload) |
| llm | startRun, only with credits on | 75 minutes |
Without realtime
When CRESCENT_EDGE_URL or REALTIME_SECRET is missing on the server, run.live is null, nothing is published and the apps poll the games list instead, showing the progress line the workflow copies every 15 seconds.
The rest of the edge Worker
edge/src/index.ts is one Worker with three jobs:
- Proxy. Every path except
/rt/*and/llm/*goes to the Next.js app atORIGIN_URL: the web app, the share pages, the well-known files, the app API and Nova's chat stream. Every method, header and body is passed on and responses stream back unbuffered, so the chat's NDJSON arrives line by line. The client never gets a redirect: GET and HEAD redirects are followed by the Worker, and any other 3xx becomes a 502. The Apple app site association file is always served as a 200application/jsonand cached for about five minutes. - Live progress, above.
- The meter. With credits on, the builder's Claude calls come through
/llm/:runId/v1/messageson anllmtoken for that run only. The Worker holds the real Anthropic key. See Credits.
Running it locally is in Development setup, and checking it in Testing.