# Live progress

> How the edge Worker in edge/ streams a build's progress to the apps through one Durable Object per run, and what else the Worker does.

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](https://docs.moonspell.app/backend/overview.md).

## The path of an event

```text
 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`:

```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

```text
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.

```text
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:

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](https://docs.moonspell.app/moonspell/credits.md).

Running it locally is in [Development setup](https://docs.moonspell.app/moonspell/dev-setup.md), and checking it in [Testing](https://docs.moonspell.app/moonspell/testing.md).
