Simulators
Verified UI
The verified UI is a Slack client, built against Slack’s own client and checked against it, that runs on the simulator’s own Web API. An agent works in a workspace through the Slack client in a browser, through the Web API or both, and a grader reads what it did. Whatever the agent does in the client lands in the simulator’s state, so a task can begin with a seeded workspace and a timed event and end with a read of the Web API:
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 verified UI is Pro and Enterprise; the Web API and the workspace UI stay on every plan. It ships as an image beside the simulator’s, registry.rystic.ai/slack-twin-ui:<ver>, pulled with the same docker login registry.rystic.ai as the simulator (your API key, or an Enterprise license file). The front door hands it out only to a plan or a license that carries verified_ui, and the UI serves only a simulator whose grant carries it, so a Free key gets a refusal page naming the feature. What a verified UI is says which plans carry it.
Run it beside the simulator
rystic ui runs the client against a running instance. It pulls the image, or, with no Docker on the machine, downloads the UI’s archive and runs it on your Node 22 or later:
rystic run slack-twin -d --scenario design_studio --env SLACK_TWIN_SOCKET_MODE=1
rystic ui slack
The first run fetches the UI with your key or license, the way rystic pull fetches the simulator, and a Free key is refused with feature_not_in_plan, naming verified_ui. -name picks another instance and -port another port. The command binds a user token to every seeded person, again after every reset, scenario load, seed load and simulator restart, and prints the URL, http://127.0.0.1:18433. Add ?as=<user id> to start as someone else, or switch person from the avatar at the foot of the rail, and theme=dark for Slack’s dark theme. Without Socket Mode the UI shows only what it reads, with no live updates.
Run it by hand or in Compose
Run the image yourself, or in Compose beside the simulator, where the UI reaches it by its service name:
docker run --rm -p 127.0.0.1:18433:18433 -e RYSTIC_TWIN_URL=http://host.docker.internal:8080 registry.rystic.ai/slack-twin-ui:<ver> # Docker Desktop
docker run --rm --network host -e RYSTIC_UI_HOST=127.0.0.1 -e RYSTIC_TWIN_URL=http://127.0.0.1:8080 registry.rystic.ai/slack-twin-ui:<ver> # a Linux daemon
services:
slack-twin:
image: registry.rystic.ai/slack-twin:<ver>
environment:
RYSTIC_TOKEN: ${RYSTIC_TOKEN}
SLACK_TWIN_SOCKET_MODE: "1"
ports: ["8080:8080"]
slack-ui:
image: registry.rystic.ai/slack-twin-ui:<ver>
environment:
RYSTIC_TWIN_URL: http://slack-twin:8080
ports: ["127.0.0.1:18433:18433"]
depends_on: [slack-twin]
On a machine that cannot pull (Enterprise), run docker save registry.rystic.ai/slack-twin-ui:<ver> | gzip > slack-twin-ui.tar.gz where you can pull and docker load where you cannot. Without Docker, rystic ui slack -fetch-only on the machine where rystic pull ran installs the archive beside the simulator. The UI talks only to the simulator, on loopback or its Compose network.
One container for a platform
A platform that packages environments as container images and lets its users write tasks on top of them runs one container per task. It holds the Slack simulator and its verified UI:
| Port | What answers |
|---|---|
8080 | the simulator: the Slack Web API at /api/<method>, and the control plane at /_rystic/ for seeding, reset and grading |
18433 | the verified UI: the Slack client a browser or a computer-use agent opens |
Build the image
The image is built from the two images each release ships, the simulator’s and its UI’s, and rebuilds neither. Save this as Dockerfile.ui-stack:
# A simulator and its verified UI in one container, behind one entrypoint and
# one health check, for a platform whose environment is a single image. It is
# built FROM the two images a release ships, the simulator's and its UI's, and
# rebuilds neither: the simulator keeps its license stamp and the UI keeps its
# plan gate, so a license without verified_ui gets the UI's 403 page here too.
#
# docker build -f Dockerfile.ui-stack \
# --build-arg TWIN_IMAGE=registry.rystic.ai/slack-twin:<ver> \
# --build-arg UI_IMAGE=registry.rystic.ai/slack-twin-ui:<ver> -t slack-env .
# docker run -d -p 8080:8080 -p 18433:18433 -e RYSTIC_LICENSE="$RYSTIC_LICENSE" \
# -e SLACK_TWIN_SOCKET_MODE=1 slack-env --scenario design_studio
#
# Arguments after the image name go to the simulator, and so does the
# container's environment. The entrypoint starts the simulator, starts the UI
# once the simulator answers (the UI's own entrypoint, which binds its tokens
# and serves), and stops the container when either process stops. The health
# check passes only while both answer 200. The build context is unused.
ARG TWIN_IMAGE
ARG UI_IMAGE
FROM ${TWIN_IMAGE} AS twin
FROM ${UI_IMAGE}
USER root
COPY --from=twin /opt/rystic /opt/rystic
COPY --chmod=755 <<'EOF' /usr/local/bin/rystic-stack
#!/bin/sh
set -u
urls=/tmp/rystic-stack.urls
if [ "${1:-}" = health ]; then
[ -r "$urls" ] || exit 1
while read -r url; do wget -q -T 3 -O /dev/null "$url" || exit 1; done < "$urls"
exit 0
fi
dir=$(dirname /opt/rystic/twins/*/current)
product=${dir##*/}
twin="http://127.0.0.1:${RYSTIC_ADDR##*:}"
eval "$(node -e 'const m = require("/ui/rystic-ui.json"); console.log(`twin_env=${m.twin_env}; ui_port=\${${m.port_env}:-${m.port}}`)')"
tp= up= stopping=0
trap 'stopping=1; kill $tp $up 2>/dev/null' INT TERM
"$dir/$(cat "$dir/current")/$product" "$@" &
tp=$!
until [ "$stopping" = 1 ] || wget -q -T 2 -O /dev/null "$twin/_rystic/version"; do
kill -0 "$tp" 2>/dev/null || break
sleep 0.2
done
if [ "$stopping" = 0 ] && kill -0 "$tp" 2>/dev/null; then
eval "export $twin_env=\"\${$twin_env:-$twin}\""
sh /ui/entrypoint.sh &
up=$!
printf '%s\n%s\n' "$twin/_rystic/version" "http://127.0.0.1:$ui_port/" > "$urls"
while [ "$stopping" = 0 ] && kill -0 "$tp" 2>/dev/null && kill -0 "$up" 2>/dev/null; do sleep 1; done
fi
kill $tp $up 2>/dev/null
wait
[ "$stopping" = 1 ]
EOF
ENV RYSTIC_ADDR=0.0.0.0:8080 \
RYSTIC_HOME=/opt/rystic \
RYSTIC_LICENSE=""
EXPOSE 8080
USER node
WORKDIR /home/node
HEALTHCHECK --interval=5s --timeout=8s --start-period=30s --retries=3 \
CMD ["/usr/local/bin/rystic-stack", "health"]
ENTRYPOINT ["/usr/local/bin/rystic-stack"]
Build it and start one environment:
docker build -f Dockerfile.ui-stack \
--build-arg TWIN_IMAGE=registry.rystic.ai/slack-twin:0.0.20 \
--build-arg UI_IMAGE=registry.rystic.ai/slack-twin-ui:0.0.20 -t slack-env .
docker run -d --name slack-env -p 8080:8080 -p 18433:18433 \
-e RYSTIC_LICENSE="$RYSTIC_LICENSE" -e SLACK_TWIN_SOCKET_MODE=1 \
slack-env --scenario design_studio
Arguments after the image name go to the simulator, and so does the container’s environment:
| Setting | Effect |
|---|---|
RYSTIC_LICENSE | your Enterprise license, the file’s JSON or its base64. A Pro plan passes RYSTIC_TOKEN, its API key, instead |
SLACK_TWIN_SOCKET_MODE=1 | live updates: the UI shows another person’s or your bot’s post as it happens. Without it, the UI shows what it reads when a page loads |
--scenario design_studio | boots the shared design studio workspace; leave it out to start empty |
--seed /path/in/container.json or RYSTIC_SEED | boots your own workspace from a scenario file, mounted into the container |
RYSTIC_PACE=1 | the simulator’s clock follows wall time, so timed events fire on their own |
SLACK_UI_ACTOR | the person the UI opens as when the URL names none (default UWDC0000001, dana) |
The UI serves only a simulator whose license or plan carries verified_ui. Under a license without it, every UI page answers 403 naming the feature, the Web API keeps working and the container’s health check fails.
Know when it is ready
The image’s health check passes once GET http://localhost:8080/_rystic/version and GET http://localhost:18433/ both answer 200, so docker inspect -f '{{.State.Health.Status}}' slack-env reads healthy. It runs every 5 seconds; a harness that wants to start sooner polls the two URLs itself. The UI’s port refuses connections until the simulator answers, because the UI starts only then; once it starts, / can answer 503 while it checks the simulator, so a readiness poll treats both as not ready yet.
On start, and again whenever the workspace changes, the UI binds a user token, xoxp-ui-<user id>, to every person in the workspace, so the client can act as anyone. The bindings land within a moment of / answering; a task that needs them checks one:
curl -s http://localhost:18433/api/auth.test -H 'Authorization: Bearer xoxp-ui-UWDC0000001'
# {"ok":true,"user":"dana","user_id":"UWDC0000001",...}
Point the browser at a conversation and a person:
http://localhost:18433/client/TWDC0000001/CWDC0000001?as=UWDC0000001
TWDC0000001 is the workspace, CWDC0000001 is #general and UWDC0000001 is dana. Leave out ?as= to open as SLACK_UI_ACTOR. The person can be switched from the avatar at the foot of the left rail.
Seed a workspace
Each task starts from a workspace you name. There are three ways to describe one, and they combine.
- A built-in scenario.
design_studiois a web design studio’s Slack a year in: sixty conversations and their threads, DMs, reactions, pins, files and bookmarks, from a cast of forty-three; Scenarios says what it holds. 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, and Scenarios and seeding covers every simulator. 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, either on its own under
RYSTIC_PACE=1or when your harness advances the clock.
The first example on this page is a timed event that posts as a person, ten minutes into the episode, with the clock advanced to fire it.
That post lands in #help (CWDC0000004) as noor typed it in the Slack client: her user, a client_msg_id and no bot_id. 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 answer into the next, is in the control plane’s timeline reference.
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. The UI stays signed in through all of them: after a reset, a scenario or seed load, 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.
curl -s -X POST http://localhost:8080/_rystic/scenario -d '{"name":"design_studio"}'
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: its 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. Until then settling reads true, 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, itsargsand whether Slack served it (ok, anderrorfor a refusal). 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"
A message posted from the UI is the one a person types in Slack: its user is that person, it carries a client_msg_id and it has no bot_id. 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.
What the UI supports
Every row of the UI’s feature list, as its own tests last left it. Works means the UI does it against the simulator and a test checks the result through the Web API; the Web API column names the methods it calls, which are what a grader reads. Write tasks against the Works rows.
| Area | Works | Gap | Out |
|---|---|---|---|
| shell | 15 | 0 | 9 |
| messages | 14 | 0 | 6 |
| reactions | 4 | 0 | 1 |
| threads | 7 | 0 | 3 |
| conversations | 28 | 1 | 8 |
| files | 9 | 0 | 5 |
| pins & bookmarks | 7 | 0 | 0 |
| people & status | 20 | 1 | 1 |
| scheduling | 8 | 0 | 1 |
| search & navigation | 14 | 1 | 4 |
| apps & views | 2 | 5 | 8 |
| sidebar | 14 | 1 | 6 |
| canvases & lists | 16 | 3 | 4 |
Works.
| Area | Feature | Web API |
|---|---|---|
| shell | app load → current actor in the rail avatar and actor switch | auth.test |
| shell | workspace name and icon at the top of the sidebar | team.info |
| shell | server holds the Socket Mode link; each event refreshes the open page | apps.connections.open |
| shell | twin reset, scenario or seed load, or credential binding → every open page redraws to the new world; a reset also clears the drafts, saved items and sidebar state the browser kept for the old one | — |
| shell | a customer’s Socket Mode bot beside the UI receives every envelope it would receive alone, and the open page still shows every write | apps.connections.open |
| shell | URLs mirror Slack: /client/ | — |
| shell | rail avatar → switch actor (one bound token per seeded person) | — |
| shell | rail in Slack’s order: Home, DMs, Activity, Files, Later, Agents & tools, Admin; the control strip at its foot: Create new, Turn on focus mode, your avatar | — |
| shell | rail foot Create new (+) → Message → pick people → Go to conversation | conversations.open |
| shell | rail foot Create new (+) → Channel → name → Create | conversations.create |
| shell | rail foot Create new (+) → Canvas → title → Create → the canvas opens at /client/ | canvases.create |
| shell | rail foot avatar (and every avatar) shows the person’s profile.image_*, the letter tile when there is none | users.info |
| shell | ?theme=dark opens Slack’s dark theme (the harness picks it, as ?as= picks the person); light is the default | — |
| shell | at 1280×720 and 1024×768 every rail, top bar, header, tab, composer and thread pane control is on screen; shorter windows scroll the rail’s tabs and keep the control strip pinned | — |
| shell | rail foot Create new (+) → List → name, and whether it tracks to-dos → Create → the List opens at /client/ | slackLists.create |
| messages | composer → type → Enter or Send button | chat.postMessage |
| messages | message list: mrkdwn, mentions, channel links, code blocks, Block Kit section/context, attachments, edited marker, day dividers | conversations.history |
| messages | composer formatting bar (bold, italic, strike, code, link, lists, quote, code block) and Show formatting toggle | chat.postMessage |
| messages | composer → Emoji button → search → pick | — |
| messages | composer → @ → autocomplete → pick person; # → channel | users.list |
| messages | composer Emoji → the picker’s Custom section after Frequently Used (aliases drawn as what they name), or search by name → pick → the image in the box; posts :name: and an emoji element | emoji.list |
| messages | message list: :name: and its aliases as the custom image, an alias of a standard emoji as that emoji, an image that can’t load as :name: | emoji.list |
| messages | message list: as an @handle pill, the group’s handle now (else the label it was posted with) → click → card with name, description, members and View user group | usergroups.list, usergroups.users.list |
| messages | composer → @ → user groups after people (up to three) → pick → @handle pill; posts and a usergroup element | usergroups.list |
| messages | own message hover → ⋯ → Edit message → change → Enter | chat.update |
| messages | own message hover → ⋯ → Delete message → confirm | chat.delete |
| messages | composer → /me waves → Enter (italic me-message) | chat.meMessage |
| messages | message hover → More actions → Copy link → clipboard + toast | chat.getPermalink |
| messages | unsent composer text survives switching conversations; Drafts & sent lists drafts | — |
| reactions | message hover → Add reaction… → search → pick; quick reaction (white_check_mark, eyes, raised_hands); click someone else’s pill; Add reaction… at the end of the pill row | reactions.add |
| reactions | reaction pill draws a custom emoji’s image; Add reaction… → the picker’s Custom section; picking an alias reuses the pill of its image | emoji.list, reactions.add |
| reactions | click your own reaction pill | reactions.remove |
| reactions | hover or focus a reaction pill → who reacted | reactions.get |
| threads | N replies → thread pane with root and replies | conversations.replies |
| threads | thread pane → Reply… → Enter; message hover → Reply in thread | chat.postMessage |
| threads | thread composer → Also send to #channel → Enter | chat.postMessage |
| threads | sidebar Threads → /client/ | conversations.history, conversations.replies |
| threads | thread pane header ⋮ → Copy link → the root’s permalink | chat.getPermalink |
| threads | Threads view → a card’s Reply… box → Enter (Also send to #channel as in the thread pane); the card moves to the top | chat.postMessage |
| threads | Threads view → a card’s conversation name → the conversation with that thread’s pane open | conversations.replies |
| conversations | sidebar: Channels, Direct messages, sorted, archived hidden | users.conversations |
| conversations | Add channels → Browse channels → list with Join / View | conversations.list |
| conversations | channel header (name, topic, member count) and channel details dialog | conversations.info |
| conversations | open a conversation → newest messages; scroll up loads older (cursor) | conversations.history |
| conversations | header member count → Members tab | conversations.members |
| conversations | Add channels → Create a new channel → name, private toggle → Create | conversations.create |
| conversations | preview a channel you’re not in → Join channel; Browse → Join | conversations.join |
| conversations | channel details → Leave channel; sidebar item menu → Leave | conversations.leave |
| conversations | channel details → Members → Add people | conversations.invite |
| conversations | channel details → Members → person → Remove from channel | conversations.kick |
| conversations | channel details → Settings → Archive channel → confirm | conversations.archive |
| conversations | archived channel → details → Unarchive channel | conversations.unarchive |
| conversations | channel details → Channel name → Edit → Save | conversations.rename |
| conversations | channel details → Topic → Edit → Save (header shows it) | conversations.setTopic |
| conversations | channel details → Description → Edit → Save | conversations.setPurpose |
| conversations | opening a conversation marks it read; message hover → ⋯ → Mark unread | conversations.mark |
| conversations | bold sidebar item with count badge, New messages divider, unread pill | — |
| conversations | New message (compose) → pick people → DM or group DM; profile → Message | conversations.open |
| conversations | sidebar DM item menu → Close conversation → the DM leaves the sidebar and the DMs list; New message to the person, or a new message from them, brings it back | conversations.close |
| conversations | channel header Search in channel, or header ⋮ → Search in channel → the top bar’s field opens holding in:# | search.messages |
| conversations | channel header ⋮ → Open channel details | conversations.info |
| conversations | DM header ⋮ → View full profile | users.info |
| conversations | channel header ⋮ → Star channel / Unstar channel → Starred section | — |
| conversations | channel header ⋮ → Copy link → the conversation’s URL in this client | — |
| conversations | channel header ⋮ → Leave channel (not in #general) | conversations.leave |
| conversations | DM header ⋮ → Close conversation | conversations.close |
| conversations | conversation tabs: Add canvas → the channel canvas, made and opened in its tab (titled Canvas while files.info can’t name it) | conversations.canvases.create |
| conversations | conversation tabs: + → Link → the bookmark dialog → Save (bookmarks.add) | bookmarks.add |
| files | composer Attach → Upload from your computer → pick a file (uploads at once, shown in the composer tray) | files.getUploadURLExternal |
| files | composer tray: bytes POSTed to upload_url with progress; failed upload → Retry | upload/v1/{ticket} |
| files | attach → Enter / Send (composer text is the initial comment; thread_ts in a thread) → message with the file | files.completeUploadExternal |
| files | click a file on a message → file viewer: name, type, size, uploader, date, shared in | files.info |
| files | rail Files → /client/ | files.list |
| files | own file → More → Delete file → confirm (or the viewer’s Delete file) | files.delete |
| files | image files: inline preview on the message and in the viewer (url_private fetched with the bearer token) | files-pri/{tf}/{name} |
| files | file More → Download, or the viewer’s Download (url_private_download with the bearer token) | files-pri/{tf}/download/{name} |
| files | rail Files → the Files sidebar’s Create new (+) → Canvas or List, the same dialogs as the rail’s Create new | canvases.create, slackLists.create |
| pins & bookmarks | message hover → More actions → Pin to this conversation; pinned messages show “Pinned by | pins.add |
| pins & bookmarks | pinned message → More actions → Un-pin from this conversation, or Pins tab → Un-pin | pins.remove |
| pins & bookmarks | conversation header → Pins tab → View message jumps to it | pins.list |
| pins & bookmarks | Bookmarks tab → Add a bookmark → link, name → Save | bookmarks.add |
| pins & bookmarks | Bookmarks tab → bookmark More → Edit → Save | bookmarks.edit |
| pins & bookmarks | conversation header → Bookmarks tab; a bookmark opens its link in a new tab | bookmarks.list |
| pins & bookmarks | Bookmarks tab → bookmark More → Remove | bookmarks.remove |
| people & status | rail People → /client/ | users.list |
| people & status | author name or directory entry → /client/ | users.info |
| people & status | profile pane: email, phone, pronouns; own Edit profile prefills from users.profile.get | users.profile.get |
| people & status | People directory → type an email → users.lookupByEmail → that person | users.lookupByEmail |
| people & status | presence dot on DM sidebar items, directory avatars and the profile; Active / Away on the profile | users.getPresence |
| people & status | your avatar menu → Set yourself as away / Set yourself as active | users.setPresence |
| people & status | app load calls users.setActive once per signed-in person | users.setActive |
| people & status | your avatar menu → Profile → Edit profile → full name, display name, title → Save Changes | users.profile.set |
| people & status | your avatar menu → Update your status → emoji, text or a suggestion, Remove status after → Save; Clear status | users.profile.set |
| people & status | Edit profile → Remove photo | users.deletePhoto |
| people & status | your avatar menu shows Pause notifications with the paused-until state (disabled); zZ and Paused until on a paused person’s avatar and profile | dnd.info |
| people & status | People directory: one dnd.teamInfo for the listed people → zZ on paused people | dnd.teamInfo |
| people & status | custom profile field labels on the profile pane | team.profile.get |
| people & status | sidebar Directories → User Groups tab: every group with its handle, member count and description; search; Show disabled groups | usergroups.list |
| people & status | User Groups → a group → /client/ | usergroups.users.list |
| people & status | User Groups → Create User Group → name, handle, description → Create → the new group’s pane; a refusal (name or handle taken) stays in the dialog | usergroups.create |
| people & status | group pane → Edit → name, handle, description → Save Changes | usergroups.update |
| people & status | group pane → Edit members → tick and untick people (find by name) → Save; at least one member | usergroups.users.update |
| people & status | group pane → Disable group → confirm → Disabled, gone from the default list | usergroups.disable |
| people & status | User Groups → Show disabled groups → a disabled group → Enable group | usergroups.enable |
| scheduling | composer send options → Tomorrow at 9:00 AM / Monday at 9:00 AM, or Custom time → date + time → Schedule Message; context bar above the composer | chat.scheduleMessage |
| scheduling | Drafts & sent → Scheduled (or the context bar’s See all scheduled messages) | chat.scheduledMessages.list |
| scheduling | Drafts & sent → Scheduled → item More → Delete message → Delete | chat.deleteScheduledMessage |
| scheduling | message More actions → Remind me (after Mark unread) → In 20 minutes, In 1 hour, In 3 hours, Tomorrow, Next week or Custom… (date and time) → a reminder whose text is the message’s permalink | reminders.add, chat.getPermalink |
| scheduling | Later → New reminder (+) or Create Reminder → what and when (a choice or Custom… date and time) → Create Reminder | reminders.add |
| scheduling | rail Later → In progress: open reminders, due soonest first, before saved messages; one about a message draws that message as the twin has it and links to it; Completed: done reminders | reminders.list |
| scheduling | Later → a reminder → Mark complete → it moves to Completed | reminders.complete |
| scheduling | Later → a reminder → Delete | reminders.delete |
| search & navigation | sidebar Find a conversation… → matching channels, DMs and people → arrows + Enter or click opens it | users.conversations |
| search & navigation | top bar back / forward through visited conversations | — |
| search & navigation | message hover → Save for later (flips to Remove from Later); rail Later → /client/ | — |
| search & navigation | conversation header Star channel / Remove from Starred → Starred section in the sidebar; drag a sidebar row onto Starred | — |
| search & navigation | top bar Search → type → Enter → /client/ | search.messages |
| search & navigation | rail Activity (between DMs and Files) → the sidebar column lists mentions of the viewer, others’ replies in the viewer’s threads and reactions to the viewer’s messages, newest first; filters All, Mentions, Threads, Reactions | conversations.history, conversations.replies |
| search & navigation | Activity → Mentions: messages and replies by others with <@viewer>, newest first | conversations.history, conversations.replies |
| search & navigation | Activity → Threads: others’ replies in threads the viewer started, or replied in (after their first reply) | conversations.replies |
| search & navigation | Activity → Reactions: one item per reaction by someone else on the viewer’s messages | conversations.history, conversations.replies |
| search & navigation | Activity item → the conversation scrolled to the message (lit and focused), or its thread pane for a reply; the sidebar stays on Activity with the item selected | conversations.history, conversations.replies |
| search & navigation | search results → Files tab with its count: each matching file by title, the query lit, its owner, date and type; a title opens the file viewer | search.files |
| search & navigation | a message result → its conversation scrolled to the message, lit and focused, or its thread pane for a reply | search.messages, conversations.replies |
| search & navigation | a query nothing matches → No results for “ | search.messages |
| search & navigation | typed in:, from:, has:, before: and after: go to the twin as typed, and the results view shows what they select | search.messages |
| apps & views | a bot’s message → Block Kit button (actions block or section accessory) → the app gets block_actions; a button’s confirm dialog asks first | blocks.actions |
| apps & views | a button press the app doesn’t answer (no app connected, no ack within 3 s, an error status) → Slack’s generic error beside the button | blocks.actions |
| sidebar | sidebar section caret → collapse/expand Starred, Channels, Direct messages, Agents & apps; remembered per person | — |
| sidebar | sidebar top: Threads, Huddles, Drafts & sent, Directories (Directories opens the People directory) | — |
| sidebar | Direct messages lists your own DM (‘you’) first | conversations.open |
| sidebar | Direct messages sorted by most recent activity | conversations.history |
| sidebar | Agents & apps section lists DMs with bots, with app badge | users.conversations |
| sidebar | workspace name ▾ → menu: workspace name, switch person; other items disabled with reason | team.info |
| sidebar | DM row hover × closes the conversation | conversations.close |
| sidebar | rail DMs → list of every DM with last message and time | users.conversations, conversations.history |
| sidebar | top bar clock → recently visited conversations | — |
| sidebar | floating Unread mentions ↑/↓ pills scroll to badged rows off screen | — |
| sidebar | rail DMs button: count of DMs with unread messages | users.conversations, conversations.info, conversations.history |
| sidebar | count beside Drafts & sent, from the local drafts | — |
| sidebar | sidebar item right-click / More: Open channel details, Copy link, Star/Unstar, Leave or Close; Mute and Move to new section disabled with reason | — |
| sidebar | Agents & apps row opens the DM; its ↗ open-in-new-window renders disabled with reason | users.conversations |
| canvases & lists | a canvas at /client/ | canvases.getContent |
| canvases & lists | a canvas’s foot: Add to this canvas → type Markdown → Enter (insert_at_end) | canvases.edit |
| canvases & lists | hover a block → Edit → change its Markdown → Save: canvases.sections.lookup by its text, then replace that section, or the whole document when no single section matches | canvases.sections.lookup, canvases.edit |
| canvases & lists | hover a block → Delete: the section deleted, or the whole document rewritten without it when no single section matches | canvases.sections.lookup, canvases.edit |
| canvases & lists | a checklist item’s box: tick or untick | canvases.sections.lookup, canvases.edit |
| canvases & lists | canvas ⋮ (More options) → Delete canvas → confirm, in the canvas view or the channel’s canvas tab; a Canvases page row’s ⋮ too | canvases.delete |
| canvases & lists | a List at /client/ | slackLists.items.list, slackLists.items.info |
| canvases & lists | a row’s Open item, or its ⋮ → Open item → the Item panel over the table: the title, Mark Complete on a to-do List, every field with its control, the subtasks, and N of M with previous and next | slackLists.items.info |
| canvases & lists | + Add item → a new Untitled item with its title in edit → type → Enter commits it and starts the next (Escape stops) | slackLists.items.create |
| canvases & lists | a cell in the table or the Item panel: text (click, type, Enter), select and person (a picker), date (a date field), checkbox, and a to-do item’s Completed circle or the panel’s Mark Complete | slackLists.items.update |
| canvases & lists | a row’s ⋯ → Delete item; or tick rows → Delete N items → confirm; a row’s subtasks go with it | slackLists.items.delete, slackLists.items.deleteMultiple |
| canvases & lists | record panel → Add subtask → type → Enter (parent_item_id); the subtask shows in the panel and under its row | slackLists.items.create |
| canvases & lists | conversation tabs: one per List conversations.info names in properties.tabs, the List inside | conversations.info, slackLists.items.list |
| canvases & lists | List header ⋯ → Rename list → Save | slackLists.update |
| canvases & lists | the outline left of a canvas: a line per heading → the headings → one jumps to it | — |
| canvases & lists | List toolbar Search → type: the rows whose title matches; Escape clears | — |
Gap: the UI shows it, and it does not yet work as it does in the real service.
| Area | Feature | Why |
|---|---|---|
| conversations | conversation tabs: one per canvas conversations.info lists in properties.tabs, titled by the canvas → the canvas in the tab, with its outline and ⋮ | The twin’s conversations.canvases.create adds no canvas tab to conversations.info’s properties.tabs, so a channel’s canvas shows only in the page that made it. |
| people & status | Edit profile → Upload photo (multipart image) | The twin’s users.setPhoto refuses a multipart/form-data body with invalid_form_data and an image sent as text with no_image, while Slack takes the image as a multipart file, so the upload shows that error. |
| search & navigation | rail Files → the Files sidebar → Canvases: + New, a search box and All / Created by you / Shared with you over the viewer’s canvases (files.list types=canvases), each opening its canvas, a row’s ⋮ → Delete canvas | The twin’s files.list lists no canvas that canvases.create made, so the browser shows none. |
| apps & views | a button press → the app opens a modal → its title, blocks, text inputs, Cancel and Submit | Waits on the twin: views.open does not yet take the trigger_id of a press, and the twin does not yet tell a person’s client that an app opened a modal for them. |
| apps & views | modal → Submit → views.submit with the text inputs’ state; an empty or clear ack closes it, and an input left empty when required, malformed (email, URL, number) or outside its length or value limits says so under it first | Waits on the twin: views.open does not yet take the trigger_id of a press, and the twin does not yet tell a person’s client that an app opened a modal for them. |
| apps & views | modal → Submit → an errors ack → each message under its input block, the modal open with what was typed | Waits on the twin: views.open does not yet take the trigger_id of a press, and the twin does not yet tell a person’s client that an app opened a modal for them. |
| apps & views | modal → Submit → an update ack → the modal swaps to the ack’s view, keeping what was typed into inputs it keeps | Waits on the twin: views.open does not yet take the trigger_id of a press, and the twin does not yet tell a person’s client that an app opened a modal for them. |
| apps & views | modal → Submit the app doesn’t answer → Slack’s generic error at the top of the modal, the inputs as typed | Waits on the twin: views.open does not yet take the trigger_id of a press, and the twin does not yet tell a person’s client that an app opened a modal for them. |
| sidebar | ‘you’ on your own DM, ‘invited you’ on a DM someone else opened | Twin: conversations.info on an IM answers no creator, so the “invited you” suffix has nothing to read. |
| canvases & lists | the canvas view’s header: the canvas’s title | The twin’s files.info answers file_not_found for a canvas, so the header says its title can’t be read. |
| canvases & lists | rail Files → the Files sidebar → Lists: + New, a search box and All / Created by you / Shared with you over the viewer’s Lists (files.list types=lists), each opening its List | The twin’s files.list lists no List that slackLists.create made, so the browser shows none. |
| canvases & lists | List header ⋯ → Download as CSV | The twin’s slackLists.download.get answers a link the twin doesn’t serve, so the download says it’s unavailable. |
Out: the UI leaves it out on purpose.
| Area | Feature | Why |
|---|---|---|
| shell | api.test | Connectivity check; no screen in the Slack client calls it. |
| shell | rail Agents & tools | Disabled, says why on click: the twin serves no app, agent or workflow directory. |
| shell | rail Admin | Disabled, says why on click: needs admin.*, which the twin doesn’t serve. |
| shell | rail foot moon: Turn on focus mode | Disabled, says why on click: the twin models no focus mode (no users.prefs or dnd.setSnooze). Real Slack’s moon is focus mode, not a theme switch. |
| shell | top bar: Chat with Slackbot AI, right of Search | Disabled, says why on click: the twin serves no Slack AI. |
| shell | top bar: Help (?) at the right end | Disabled, says why on click: Slack’s help center and feedback aren’t part of the twin. |
| shell | rail foot Create new (+) → Huddle | Disabled, says why on click: the twin serves no huddle API. |
| shell | rail foot Create new (+) → Workflow | Disabled, says why on click: the twin serves no workflows. |
| shell | rail foot Create new (+) → Invite people | Disabled, says why on click: needs admin.users.invite, which the twin doesn’t serve. |
| messages | chat.postEphemeral | No client control sends one; showing a received one needs a client delivery the twin keeps on the control plane. |
| messages | chat.startStream | Bot token only; the client never starts a stream, and renders a streamed message as any other. |
| messages | chat.appendStream | Bot token only; no client control. |
| messages | chat.stopStream | Bot token only; no client control. |
| messages | composer toolbar: More options (…), after Mention someone | Disabled, says why on click: shortcuts and workflows come from apps, which the twin doesn’t serve. |
| messages | message hover → Forward message…, between Reply in thread and Save for later | Disabled, says why on click: Slack forwards through a client method the Web API doesn’t publish; chat.postMessage with a permalink would post a different message than a forward. |
| reactions | reactions.list | No Slack client screen lists the items a person reacted to. |
| threads | thread pane header: Summarize thread | Disabled, says why on click: the twin serves no Slack AI. |
| threads | thread pane header ⋮ → Get notified about new replies | Disabled, says why on click: needs subscriptions.thread.*, which the twin doesn’t serve. |
| threads | thread pane header ⋮ → Mark unread | Disabled, says why on click: a thread’s read mark needs subscriptions.thread.*, which the twin doesn’t serve; conversations.mark would move the channel’s read mark instead. |
| conversations | channel header: Start huddle (headphones) and More Huddles options (chevron) | Disabled, says why on click: the twin serves no huddle API. |
| conversations | channel header bell (Edit notifications) and header ⋮ → Edit notifications | Disabled, says why on click: needs users.prefs, which the twin doesn’t serve. |
| conversations | channel header ⋮ → Open in new window | Disabled, says why on click: this client is a single window. |
| conversations | conversation tabs: + → File, canvas, or list (New) | Disabled, says why on click: Slack adds the tab with conversations.addTab, which the twin doesn’t serve. |
| conversations | conversation tabs: + → Workflow | Disabled, says why on click: the twin serves no workflows. |
| conversations | conversation tabs: + → Folder | Disabled, says why on click: bookmark folders aren’t part of the twin. |
| conversations | conversation tabs: + → Add template to channel | Disabled, says why on click: the twin serves no channel templates. |
| conversations | conversation tabs: + → Edit tabs | Disabled, says why on click: no write reorders or removes a channel’s tabs. |
| files | upload/v1/{ticket} (PUT) | The client POSTs the bytes; PUT is the same upload over another verb. |
| files | rail Files → the Files sidebar → Deleted | Disabled, says why on click: the twin serves no read of deleted canvases and Lists. |
| files | rail Files → the Files sidebar → Starred | Disabled, says why on click: needs stars.list, which the twin doesn’t serve. |
| files | rail Files → the Files sidebar’s More options | Disabled, says why on click: the Files sidebar’s settings aren’t part of the twin. |
| files | the Canvases and Lists pages’ Templates row and See all | Not drawn: the twin serves no canvas or List templates. |
| people & status | users.identity | Sign in with Slack identity endpoint; the client has no screen for it. |
| scheduling | reminders.info | Later draws each reminder from reminders.list; no screen reads one reminder alone. |
| search & navigation | Later → Archived tab | Disabled, says why on click: archiving a Later item is Slack’s saved.* state, which the twin doesn’t serve. |
| search & navigation | Later header → Filter | Disabled, says why on click: Later’s filters read Slack’s saved.* state, which the twin doesn’t serve. |
| search & navigation | Huddles | No huddle API in the twin. |
| search & navigation | search.all | The results view asks search.messages and search.files, one per tab, so each tab pages on its own; search.all answers the same two lists and no other screen needs it. |
| apps & views | views.publish | Bot write; the twin exposes no read or event a client could render App Home from. |
| apps & views | views.update | Bot write; same as views.open. |
| apps & views | a message’s selects, overflow menus and date pickers | Drawn disabled: the twin’s blocks.actions presses buttons only. |
| apps & views | a modal’s selects, date pickers, checkboxes and radio buttons | Drawn disabled: this client draws only a modal’s text inputs so far. |
| apps & views | a button inside a modal | Drawn disabled: the twin’s blocks.actions presses a message’s buttons, not a modal’s. |
| apps & views | a push ack → the pushed view over the open one, with Back | The twin serves no views.push and mints no view for a pushed one, so the client could not submit it; a push ack closes the modal. |
| apps & views | closing a modal whose view sets notify_on_close → the app gets view_closed | The twin delivers no view_closed, so closing a modal tells the app nothing. |
| apps & views | a modal input whose conversation picker sets response_url_enabled → response_urls in the submission | The twin’s view_submission always carries an empty response_urls. |
| sidebar | Invite people / Connect apps | Needs admin.*, which the twin doesn’t serve; renders disabled. |
| sidebar | sidebar gear (Manage my sidebar) | Needs users.prefs, which the twin doesn’t serve; renders disabled. |
| sidebar | Welcome back / onboarding banners | No backing data in the twin. |
| sidebar | sidebar item menu → Mute | Needs users.prefs, which the twin doesn’t serve; renders disabled. |
| sidebar | Create a section / Move to new section | Slack keeps sections in prefs the twin doesn’t serve; Move to new section renders disabled. |
| sidebar | section options: show unreads only, sort | Show unreads only / sort need users.prefs; not rendered. |
| canvases & lists | List toolbar All items ▾ | Disabled, says why on click: the twin serves no List views. |
| canvases & lists | List toolbar Edit view | Disabled, says why on click: the twin serves no List views. |
| canvases & lists | Item panel → Add Comment | Disabled, says why on click: comments on an item aren’t part of the twin. |
| canvases & lists | a standalone List’s header Share, Workflows and the info button | Not drawn: no recording shows the dialogs they open (the capture shows only a channel’s Share this list?, reached through conversations.addTab). |
Measured footprint
Measured in November 2026 on a Linux x86_64 box with 8 vCPUs and 31 GB (an AWS m7i.2xlarge shared with other jobs), with every simulator and UI server 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. One environment’s simulator and UI server hold about 130 MB together and use about a fifth of a core while an agent works in the UI, and almost nothing while it is idle.
Cold start, for one environment: the simulator answered in 282 ms, the UI bound its tokens in 140 ms more and answered 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 answered at 398 ms, its UI at 604 ms, and 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, and the run stopped there, before 16 and 32. Memory was far from the limit, under 1 GB for all eight; the simulators, UI servers and their browser pages shared the 8 vCPUs. 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.
How it was measured. Each step started its environments from the same builds, each in design_studio with live updates on, and opened one headless Chromium page per environment. It held the pages idle for 15 seconds, then for 60 seconds every page at once opened #help, posted, opened a thread in #general and replied, and switched to another person and back. Then 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, and 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 answers a server error, never answers or answers "ok": false, and 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 page never draws, when more than 1% of calls and actions fail, or when 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), and the run stops at the first one that does.
Limits
- One workspace per container. Run one container per parallel episode; the measured footprint above says how many fit on a box.
- The UI’s browser state is the browser’s. A reset clears what the UI kept for the last episode, but a harness that reuses a browser profile across tasks should still start each task at a URL that names its person.
- Events need a clock. Without
RYSTIC_PACEthe clock moves only when you advance it or a request takes a simulated step, so a timed event fires when your harness says. - The simulator’s own limits are on the Slack simulator page.