How the playground works
The playground is a twin, not a forge behind the site. It has two inputs, one engine and one rule — nothing on screen is typed by hand.
What is real, what is simulated
| on screen | where it comes from | real or simulated |
|---|---|---|
a hop's source (file:line) | the recording, resolved against the model's capability → source map | real |
| a hop's request and response | captured by the recorder (payloads bounded, secrets redacted) | real · recorded |
| events and their data | GET /events + GET /app-events?subject=trace:… during the recording | real · recorded |
| timings | the recording's durations | recorded, replayed at 520 ms per hop |
| state rows (resources, tables) | GET /resources before/after + row counts; modelled as named rows with a status | modelled · not a live database |
| the error card | the real ForgeError envelope from the recording + fixes authored by a reviewer | real envelope · authored fixes |
| the skill mapping (ask → capability) | the forge skill set, once it exists | assumed until the skill ships |
| free-text ask | v1: a picker; v2: matched to a scenario by the skill's action vocabulary | v2 |
| your code | — | never runs · no containers per visitor |
A scenario fixture
{
"key": "provision", "forge": "v1.56.0",
"title": "A non-technical builder adds a database",
"ask": "Add a Postgres database to hello and restart it.",
"skill": "forge → provision-environment · run-dev-server",
"plane": "control · :3717",
"state0": [ ["Environment", "i-db", "ok", "web only"], … ],
"hops": [
{ "where": "control", "src": "provision-environment/converge.ts:83-104",
"what": "converge: flags + manifest + existing compose → desired infra",
"tag": "control-plane", "ms": 2,
"io": { "req": "…", "res": "…" },
"ev": ["fe", "EnvironmentProvisioned", "postgres=true"],
"st": [ ["Environment", "upd", "web + postgres"] ] }
],
"err": { "title": "…", "reason": "…", "trace": "…", "fixes": [ … ] },
"fixed": { "plane": "…", "hops": [ … ] }
}What Run does, step by step
Load the fixture; render every hop grey; render state0; hide events; k = 0.
Every 520 ms (0 under reduced motion): apply(k+1) — hops < k ✓, hop k ▶ selected with its payload; events of hops < k shown with timestamps from cumulative ms; state = state0 + mutations of hops < k, last mutation highlighted.
At k = N: the summary (hops · state rows touched · events · simulated time) and the scrubber, whose value is k. Scrubbing calls apply(k) and stops the timer.
A hop with fail turns red; the run stops at N; the error card renders from err. A fix with an id loads the fixed variant and runs again. Nothing is ever a dead end.
How a scenario is recorded
| step | what the recorder does | reads |
|---|---|---|
| 1 | provision a throwaway control plane from forge-starter in CI, pinned to the forge tag | ./new-app · compose.yaml |
| 2 | run the ask (through the skill) or the commands (CLI), with a fresh traceparent | POST /capabilities/:slug |
| 3 | collect everything the run recorded | GET /events · GET /app-events?subject=trace:… · GET /logs/:id |
| 4 | turn each event / log line into a hop; resolve its source through the model's capability → file map; bound and redact payloads | the platform model |
| 5 | diff state before and after | GET /resources · table row counts |
| 6 | write the fixture; a reviewer adds the prose (what, ask, fixes) — the only hand-typed fields | scenarios/<key>.json |
Non-goals, and what step 4 must decide
Running a visitor's code. A container per visitor. A live database. Anything that needs a server behind the site: the twin is static files plus the browser.
Which repo owns the generator and the recorder (forge, or the site). Whether fixtures re-record on every forge tag (recommended: yes; the diff is the changelog). How free-text asks match scenarios once the skill exists.
The scenario table (lib/scenarios.ts) and the engine (PlaygroundClient.tsx) are the reference implementation of what is described above. The generator and the recorder are new.