Skip to main content

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.sh starts node helpers.cjs relay, which reads new lines of progress.jsonl about 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".
  • The workflow. status: running when it claims the run, then build and status: succeeded, or status: failed, at the end. With credits on it also publishes an assets event when it asks the player to approve art and sounds. Each batch gets a fresh, short-lived publish token (publishEvents() in src/lib/realtime.ts, 5 second timeout, never throws).
  • The 15-second poll. pollRun copies the newest progress line into game_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 type is 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, no events array, empty, no type), 413 (too many events or too large), 401 (bad token), 503 (REALTIME_SECRET not 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 succeeded or failed, 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 with 1000 "run expired" and deletes the object's storage.
  • Run ids must match [A-Za-z0-9._:-]{1,128}. The object for a run is RUN_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.

TokenMinted byLifetime
subscribesubscribeUrl(), returned as run.live.url in every active run the app API hands out15 minutes
publish, server sidepublishEvents(), per batch5 minutes
publish, the box's relaystartRun75 minutes (the run's budget plus setup and upload)
llmstartRun, only with credits on75 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:

  1. Proxy. Every path except /rt/* and /llm/* goes to the Next.js app at ORIGIN_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 200 application/json and cached for about five minutes.
  2. Live progress, above.
  3. The meter. With credits on, the builder's Claude calls come through /llm/:runId/v1/messages on an llm token 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.