Skip to Content
SimulatorsSlackVerified UI tasks

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.

  1. A built-in scenario. Scenarios says what design_studio holds. Boot it with --scenario design_studio, or load it into a running container with POST /_rystic/scenario {"name":"design_studio"}.
  2. A scenario file. Users, channels, memberships and message history as Slack’s own objects, with a pinned clock, credentials and events. Seeding state has the shape and an example. Load it with POST /_rystic/seed, or boot it with --seed.
  3. 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=1 or 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:

  • generation rises 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.
  • settling reads true until 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.

  1. The Web API, as any Slack client reads it: conversations.history, conversations.replies, reactions.get, pins.list and the rest, with a bound token or with X-Rystic-Actor: <user id> on any token.
  2. GET /_rystic/inspect: the whole workspace as a register map, the shape POST /_rystic/seed takes.
  3. GET /_rystic/calls: every request since the last reset, with the principal that 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.

EnvironmentsSimulator memory, idle / busySimulator CPU, idle / busyUI server memory, idle / busyUI server CPU, idle / busyWeb API p50 / p95UI action p50 / p95Reset to redrawn page p50 / p95Failed calls
144 / 51 MB0.2 / 14.8%70 / 75 MB0.5 / 5.8%8 / 35 ms160 / 287 ms676 / 735 ms0 of 6,692
445 / 50 MB0.2 / 10.9%69 / 76 MB0.6 / 4.6%16 / 62 ms227 / 371 ms988 / 1,180 ms0 of 19,810
844 / 49 MB0.5 / 7.5%70 / 75 MB0.6 / 3.7%26 / 123 ms344 / 577 ms1,571 / 1,910 ms0 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.
Last updated on