Simulators
Verified UI tasks
A task on the verified UI starts from a workspace you seed and can fire events while the agent works. It ends with a grader reading the simulator’s state. Bind a person’s UI token, queue a post as that person ten minutes in, move the clock and read the channel:
curl -s -X POST "$RYSTIC_URL/_rystic/scenario" -d '{"name":"design_studio"}' -o /dev/null
curl -s -X POST "$RYSTIC_URL/_rystic/credentials" -H 'Content-Type: application/json' -d '{"bindings":{"xoxp-ui-UWDC0000002":"UWDC0000002"}}' -o /dev/null -w '%{http_code}\n'
# -> 204
curl -s -X POST "$RYSTIC_URL/_rystic/inject" -H 'Content-Type: application/json' -d '{"events":[{"id":"noor-asks","at":"+10m","as":"UWDC0000002","call":{"method":"POST","path":"/api/chat.postMessage","headers":{"X-Rystic-Surface":"client"},"body":{"channel":"CWDC0000004","text":"Can someone check the Hartwell invoice?"}}}]}' -o /dev/null -w '%{http_code}\n'
# -> 204
curl -s -X POST "$RYSTIC_URL/_rystic/clock" -d '{"advance_seconds": 600}' -o /dev/null -w '%{http_code}\n'
# -> 204
curl -s "$RYSTIC_URL/api/conversations.history?channel=CWDC0000004&limit=1" -H 'Authorization: Bearer xoxb-local' | jq -c '.messages[0] | {user, text, bot_id}'
# -> {"user":"UWDC0000002","text":"Can someone check the Hartwell invoice?","bot_id":null}
The post lands in #help (CWDC0000004) as noor typed it in the Slack client: her user, a client_msg_id and no bot_id.
Seed a workspace
Describe a task’s workspace in any of three ways, and combine them.
- A built-in scenario. Scenarios says what
design_studioholds. Boot it with--scenario design_studio, or load it into a running container withPOST /_rystic/scenario {"name":"design_studio"}. - A scenario file. Users, channels, memberships and message history as Slack’s own objects, with a pinned
clock,credentialsandevents. Seeding state has the shape and an example. Load it withPOST /_rystic/seed, or boot it with--seed. - Timed events. Something that happens during the episode: a teammate posts, someone reacts. An event fires when the simulator’s clock reaches it, on its own under
RYSTIC_PACE=1or when your harness advances the clock.
An event’s as needs a credential bound to that person, which the UI’s bindings provide. A scenario file can carry its own credentials and events instead. The full event format, including events that bind one call’s response into the next, is on the timelines page.
Reset between episodes
POST /_rystic/scenario {"name":"design_studio"} resets the simulator and loads the scenario again in one call. POST /_rystic/reset leaves an empty workspace, and a POST /_rystic/seed after it loads yours.
curl -s -X POST http://localhost:8080/_rystic/scenario -d '{"name":"design_studio"}'
The UI stays signed in through all of these, and through a credential binding or a simulator restart. It binds every person’s token again before it redraws an open page. It binds with keep, so a binding your harness or scenario file made stays as it is.
An open browser page redraws by itself after any of these; nobody reloads it. To know the redraw has happened, read GET http://localhost:18433/world.json:
generationrises once the changes stop for 150 ms (one second after the first at most) and the UI has bound its tokens again. The page then reloads signed in as the same person.settlingreadstrueuntil then, and a page opened meanwhile waits for it.
After a reset the page also forgets the drafts and sidebar state it kept for the last episode. A page whose person has no bound token, as on an empty workspace, says it can’t sign in rather than act as anyone else.
Read the end state
Everything an agent did, in the UI or through the Web API, is in the simulator’s state and its call log.
- The Web API, as any Slack client reads it:
conversations.history,conversations.replies,reactions.get,pins.listand the rest, with a bound token or withX-Rystic-Actor: <user id>on any token. GET /_rystic/inspect: the whole workspace as a register map, the shapePOST /_rystic/seedtakes.GET /_rystic/calls: every request since the last reset, with theprincipalthat made it (Identities describes each entry). A post from the UI carries the acting person’s token, so the log says who did what.
A grader that checks dana replied in the studio-closure thread in #general:
import requests
B = "http://localhost:8080"
r = requests.get(f"{B}/api/conversations.replies",
params={"channel": "CWDC0000001", "ts": "1770993963.000133"},
headers={"Authorization": "Bearer xoxp-ui-UWDC0000001"}).json()
assert r["ok"], r
replies = [m for m in r["messages"][1:] if m.get("user") == "UWDC0000001"]
assert replies, "dana did not reply in the thread"
Message ts values come from the simulator’s clock, which design_studio pins at 2026-03-01T00:00:00Z, so compare text and authors rather than timestamps.
Measured footprint
Plan on about 130 MB and a fifth of a core per environment for the simulator and the UI server, plus the agent’s browser. While idle, they use almost no CPU. Measured in November 2026 on a Linux x86_64 box with 8 vCPUs and 31 GB, an AWS m7i.2xlarge shared with other jobs. Every simulator and UI server ran on the same box as the browser that drove them.
| Environments | Simulator memory, idle / busy | Simulator CPU, idle / busy | UI server memory, idle / busy | UI server CPU, idle / busy | Web API p50 / p95 | UI action p50 / p95 | Reset to redrawn page p50 / p95 | Failed calls |
|---|---|---|---|---|---|---|---|---|
| 1 | 44 / 51 MB | 0.2 / 14.8% | 70 / 75 MB | 0.5 / 5.8% | 8 / 35 ms | 160 / 287 ms | 676 / 735 ms | 0 of 6,692 |
| 4 | 45 / 50 MB | 0.2 / 10.9% | 69 / 76 MB | 0.6 / 4.6% | 16 / 62 ms | 227 / 371 ms | 988 / 1,180 ms | 0 of 19,810 |
| 8 | 44 / 49 MB | 0.5 / 7.5% | 70 / 75 MB | 0.6 / 3.7% | 26 / 123 ms | 344 / 577 ms | 1,571 / 1,910 ms | 0 of 26,152 |
Memory and CPU are per process, averaged over the environments; 100% CPU is one core.
Cold start, for one environment: the simulator responded in 282 ms, and the UI bound its tokens in 140 ms more. The UI responded in another 112 ms, and a browser drew the first page 679 ms after opening it. From docker run, with an empty workspace, the container’s simulator responded at 398 ms and its UI at 604 ms. The health check, whose first run is 5 seconds in, passed at 5.7 s.
How many fit on a box. Four environments ran side by side with no failed call and every p95 within about twice one environment’s. At eight, still with no failed call, the Web API p95 reached 123 ms, 3.5 times one environment’s 35 ms. The run stopped there, before 16 and 32. Memory stayed under 1 GB for all eight; the simulators, UI servers and their browser pages shared the 8 vCPUs.
How it was measured. Each step started its environments from the same builds, each in design_studio with live updates on. It opened one headless Chromium page per environment and held the pages idle for 15 seconds. Then, for 60 seconds, every page at once opened #help and posted, opened a thread in #general and replied, then switched to another person and back. Last, it reset each environment three times, reloading the scenario and binding the tokens again, and timed each until the page had redrawn.
Memory and CPU were sampled every second for every simulator and UI server process. The Web API times are every call the pages made, from the browser through the UI server to the simulator. A call fails when it returns a server error, gets no response or returns "ok": false. A post or reply counts only once the simulator has accepted it and the page shows it at the ts the simulator returned.
A step counts as degraded when any of these holds, and the run stops at the first step that does:
- any page never draws;
- more than 1% of calls and actions fail;
- a p95 passes three times the one-environment step’s, with floors of 100 ms for the Web API, 2 s for a UI action and 5 s for a redraw.