Skip to Content
SimulatorsSlackVerified UI

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:

PortWhat answers
8080the simulator: the Slack Web API at /api/<method>, and the control plane at /_rystic/ for seeding, reset and grading
18433the 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:

SettingEffect
RYSTIC_LICENSEyour Enterprise license, the file’s JSON or its base64. A Pro plan passes RYSTIC_TOKEN, its API key, instead
SLACK_TWIN_SOCKET_MODE=1live 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_studioboots the shared design studio workspace; leave it out to start empty
--seed /path/in/container.json or RYSTIC_SEEDboots your own workspace from a scenario file, mounted into the container
RYSTIC_PACE=1the simulator’s clock follows wall time, so timed events fire on their own
SLACK_UI_ACTORthe 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.

  1. A built-in scenario. design_studio is 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 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, and Scenarios and seeding covers every simulator. 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, either on its own under RYSTIC_PACE=1 or 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.

  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, its args and whether Slack served it (ok, and error for 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.

AreaWorksGapOut
shell1509
messages1406
reactions401
threads703
conversations2818
files905
pins & bookmarks700
people & status2011
scheduling801
search & navigation1414
apps & views258
sidebar1416
canvases & lists1634

Works.

AreaFeatureWeb API
shellapp load → current actor in the rail avatar and actor switchauth.test
shellworkspace name and icon at the top of the sidebarteam.info
shellserver holds the Socket Mode link; each event refreshes the open pageapps.connections.open
shelltwin 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—
shella customer’s Socket Mode bot beside the UI receives every envelope it would receive alone, and the open page still shows every writeapps.connections.open
shellURLs mirror Slack: /client//[/thread/]—
shellrail avatar → switch actor (one bound token per seeded person)—
shellrail 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—
shellrail foot Create new (+) → Message → pick people → Go to conversationconversations.open
shellrail foot Create new (+) → Channel → name → Createconversations.create
shellrail foot Create new (+) → Canvas → title → Create → the canvas opens at /client//canvases/; the Files sidebar’s Create new and the Canvases page’s + New open the same dialogcanvases.create
shellrail foot avatar (and every avatar) shows the person’s profile.image_*, the letter tile when there is noneusers.info
shell?theme=dark opens Slack’s dark theme (the harness picks it, as ?as= picks the person); light is the default—
shellat 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—
shellrail foot Create new (+) → List → name, and whether it tracks to-dos → Create → the List opens at /client//lists/; the Files sidebar’s Create new and the Lists page’s + New open the same dialogslackLists.create
messagescomposer → type → Enter or Send buttonchat.postMessage
messagesmessage list: mrkdwn, mentions, channel links, code blocks, Block Kit section/context, attachments, edited marker, day dividersconversations.history
messagescomposer formatting bar (bold, italic, strike, code, link, lists, quote, code block) and Show formatting togglechat.postMessage
messagescomposer → Emoji button → search → pick—
messagescomposer → @ → autocomplete → pick person; # → channelusers.list
messagescomposer 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 elementemoji.list
messagesmessage 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
messagesmessage 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 groupusergroups.list, usergroups.users.list
messagescomposer → @ → user groups after people (up to three) → pick → @handle pill; posts and a usergroup elementusergroups.list
messagesown message hover → ⋯ → Edit message → change → Enterchat.update
messagesown message hover → ⋯ → Delete message → confirmchat.delete
messagescomposer → /me waves → Enter (italic me-message)chat.meMessage
messagesmessage hover → More actions → Copy link → clipboard + toastchat.getPermalink
messagesunsent composer text survives switching conversations; Drafts & sent lists drafts—
reactionsmessage 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 rowreactions.add
reactionsreaction pill draws a custom emoji’s image; Add reaction… → the picker’s Custom section; picking an alias reuses the pill of its imageemoji.list, reactions.add
reactionsclick your own reaction pillreactions.remove
reactionshover or focus a reaction pill → who reactedreactions.get
threadsN replies → thread pane with root and repliesconversations.replies
threadsthread pane → Reply… → Enter; message hover → Reply in threadchat.postMessage
threadsthread composer → Also send to #channel → Enterchat.postMessage
threadssidebar Threads → /client//threads: every thread the viewer started or replied in, newest activity first; each card shows the root, the latest two replies (Show N more replies for the rest) and a reply boxconversations.history, conversations.replies
threadsthread pane header ⋮ → Copy link → the root’s permalinkchat.getPermalink
threadsThreads view → a card’s Reply… box → Enter (Also send to #channel as in the thread pane); the card moves to the topchat.postMessage
threadsThreads view → a card’s conversation name → the conversation with that thread’s pane openconversations.replies
conversationssidebar: Channels, Direct messages, sorted, archived hiddenusers.conversations
conversationsAdd channels → Browse channels → list with Join / Viewconversations.list
conversationschannel header (name, topic, member count) and channel details dialogconversations.info
conversationsopen a conversation → newest messages; scroll up loads older (cursor)conversations.history
conversationsheader member count → Members tabconversations.members
conversationsAdd channels → Create a new channel → name, private toggle → Createconversations.create
conversationspreview a channel you’re not in → Join channel; Browse → Joinconversations.join
conversationschannel details → Leave channel; sidebar item menu → Leaveconversations.leave
conversationschannel details → Members → Add peopleconversations.invite
conversationschannel details → Members → person → Remove from channelconversations.kick
conversationschannel details → Settings → Archive channel → confirmconversations.archive
conversationsarchived channel → details → Unarchive channelconversations.unarchive
conversationschannel details → Channel name → Edit → Saveconversations.rename
conversationschannel details → Topic → Edit → Save (header shows it)conversations.setTopic
conversationschannel details → Description → Edit → Saveconversations.setPurpose
conversationsopening a conversation marks it read; message hover → ⋯ → Mark unreadconversations.mark
conversationsbold sidebar item with count badge, New messages divider, unread pill—
conversationsNew message (compose) → pick people → DM or group DM; profile → Messageconversations.open
conversationssidebar 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 backconversations.close
conversationschannel header Search in channel, or header ⋮ → Search in channel → the top bar’s field opens holding in:# (in:@ in a DM) → type → Enter → the results viewsearch.messages
conversationschannel header ⋮ → Open channel detailsconversations.info
conversationsDM header ⋮ → View full profileusers.info
conversationschannel header ⋮ → Star channel / Unstar channel → Starred section—
conversationschannel header ⋮ → Copy link → the conversation’s URL in this client—
conversationschannel header ⋮ → Leave channel (not in #general)conversations.leave
conversationsDM header ⋮ → Close conversationconversations.close
conversationsconversation tabs: Add canvas → the channel canvas, made and opened in its tab (titled Canvas while files.info can’t name it)conversations.canvases.create
conversationsconversation tabs: + → Link → the bookmark dialog → Save (bookmarks.add)bookmarks.add
filescomposer Attach → Upload from your computer → pick a file (uploads at once, shown in the composer tray)files.getUploadURLExternal
filescomposer tray: bytes POSTed to upload_url with progress; failed upload → Retryupload/v1/{ticket}
filesattach → Enter / Send (composer text is the initial comment; thread_ts in a thread) → message with the filefiles.completeUploadExternal
filesclick a file on a message → file viewer: name, type, size, uploader, date, shared infiles.info
filesrail Files → /client//files: type filter, Shared by me → click opens the viewerfiles.list
filesown file → More → Delete file → confirm (or the viewer’s Delete file)files.delete
filesimage files: inline preview on the message and in the viewer (url_private fetched with the bearer token)files-pri/{tf}/{name}
filesfile More → Download, or the viewer’s Download (url_private_download with the bearer token)files-pri/{tf}/download/{name}
filesrail Files → the Files sidebar’s Create new (+) → Canvas or List, the same dialogs as the rail’s Create newcanvases.create, slackLists.create
pins & bookmarksmessage hover → More actions → Pin to this conversation; pinned messages show “Pinned by ” and a highlightpins.add
pins & bookmarkspinned message → More actions → Un-pin from this conversation, or Pins tab → Un-pinpins.remove
pins & bookmarksconversation header → Pins tab → View message jumps to itpins.list
pins & bookmarksBookmarks tab → Add a bookmark → link, name → Savebookmarks.add
pins & bookmarksBookmarks tab → bookmark More → Edit → Savebookmarks.edit
pins & bookmarksconversation header → Bookmarks tab; a bookmark opens its link in a new tabbookmarks.list
pins & bookmarksBookmarks tab → bookmark More → Removebookmarks.remove
people & statusrail People → /client//people: search by name or title → click a person → profile paneusers.list
people & statusauthor name or directory entry → /client///user_profile/: name, display name, title, status, presence, local time, Messageusers.info
people & statusprofile pane: email, phone, pronouns; own Edit profile prefills from users.profile.getusers.profile.get
people & statusPeople directory → type an email → users.lookupByEmail → that personusers.lookupByEmail
people & statuspresence dot on DM sidebar items, directory avatars and the profile; Active / Away on the profileusers.getPresence
people & statusyour avatar menu → Set yourself as away / Set yourself as activeusers.setPresence
people & statusapp load calls users.setActive once per signed-in personusers.setActive
people & statusyour avatar menu → Profile → Edit profile → full name, display name, title → Save Changesusers.profile.set
people & statusyour avatar menu → Update your status → emoji, text or a suggestion, Remove status after → Save; Clear statususers.profile.set
people & statusEdit profile → Remove photousers.deletePhoto
people & statusyour avatar menu shows Pause notifications with the paused-until state (disabled); zZ and Paused until on a paused person’s avatar and profilednd.info
people & statusPeople directory: one dnd.teamInfo for the listed people → zZ on paused peoplednd.teamInfo
people & statuscustom profile field labels on the profile paneteam.profile.get
people & statussidebar Directories → User Groups tab: every group with its handle, member count and description; search; Show disabled groupsusergroups.list
people & statusUser Groups → a group → /client//people/usergroup/: name, handle, description and members; a member opens their profileusergroups.users.list
people & statusUser Groups → Create User Group → name, handle, description → Create → the new group’s pane; a refusal (name or handle taken) stays in the dialogusergroups.create
people & statusgroup pane → Edit → name, handle, description → Save Changesusergroups.update
people & statusgroup pane → Edit members → tick and untick people (find by name) → Save; at least one memberusergroups.users.update
people & statusgroup pane → Disable group → confirm → Disabled, gone from the default listusergroups.disable
people & statusUser Groups → Show disabled groups → a disabled group → Enable groupusergroups.enable
schedulingcomposer send options → Tomorrow at 9:00 AM / Monday at 9:00 AM, or Custom time → date + time → Schedule Message; context bar above the composerchat.scheduleMessage
schedulingDrafts & sent → Scheduled (or the context bar’s See all scheduled messages)chat.scheduledMessages.list
schedulingDrafts & sent → Scheduled → item More → Delete message → Deletechat.deleteScheduledMessage
schedulingmessage 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 permalinkreminders.add, chat.getPermalink
schedulingLater → New reminder (+) or Create Reminder → what and when (a choice or Custom… date and time) → Create Reminderreminders.add
schedulingrail 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 remindersreminders.list
schedulingLater → a reminder → Mark complete → it moves to Completedreminders.complete
schedulingLater → a reminder → Deletereminders.delete
search & navigationsidebar Find a conversation… → matching channels, DMs and people → arrows + Enter or click opens itusers.conversations
search & navigationtop bar back / forward through visited conversations—
search & navigationmessage hover → Save for later (flips to Remove from Later); rail Later → /client//later—
search & navigationconversation header Star channel / Remove from Starred → Starred section in the sidebar; drag a sidebar row onto Starred—
search & navigationtop bar Search → type → Enter → /client//search/search-: Messages tab with its count, each match under its conversation with the query’s words lit, N results, Previous/Next pagessearch.messages
search & navigationrail 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, Reactionsconversations.history, conversations.replies
search & navigationActivity → Mentions: messages and replies by others with <@viewer>, newest firstconversations.history, conversations.replies
search & navigationActivity → Threads: others’ replies in threads the viewer started, or replied in (after their first reply)conversations.replies
search & navigationActivity → Reactions: one item per reaction by someone else on the viewer’s messagesconversations.history, conversations.replies
search & navigationActivity 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 selectedconversations.history, conversations.replies
search & navigationsearch results → Files tab with its count: each matching file by title, the query lit, its owner, date and type; a title opens the file viewersearch.files
search & navigationa message result → its conversation scrolled to the message, lit and focused, or its thread pane for a replysearch.messages, conversations.replies
search & navigationa query nothing matches → No results for “”, with a tipsearch.messages
search & navigationtyped in:, from:, has:, before: and after: go to the twin as typed, and the results view shows what they selectsearch.messages
apps & viewsa bot’s message → Block Kit button (actions block or section accessory) → the app gets block_actions; a button’s confirm dialog asks firstblocks.actions
apps & viewsa button press the app doesn’t answer (no app connected, no ack within 3 s, an error status) → Slack’s generic error beside the buttonblocks.actions
sidebarsidebar section caret → collapse/expand Starred, Channels, Direct messages, Agents & apps; remembered per person—
sidebarsidebar top: Threads, Huddles, Drafts & sent, Directories (Directories opens the People directory)—
sidebarDirect messages lists your own DM (‘you’) firstconversations.open
sidebarDirect messages sorted by most recent activityconversations.history
sidebarAgents & apps section lists DMs with bots, with app badgeusers.conversations
sidebarworkspace name ▾ → menu: workspace name, switch person; other items disabled with reasonteam.info
sidebarDM row hover × closes the conversationconversations.close
sidebarrail DMs → list of every DM with last message and timeusers.conversations, conversations.history
sidebartop bar clock → recently visited conversations—
sidebarfloating Unread mentions ↑/↓ pills scroll to badged rows off screen—
sidebarrail DMs button: count of DMs with unread messagesusers.conversations, conversations.info, conversations.history
sidebarcount beside Drafts & sent, from the local drafts—
sidebarsidebar item right-click / More: Open channel details, Copy link, Star/Unstar, Leave or Close; Mute and Move to new section disabled with reason—
sidebarAgents & apps row opens the DM; its ↗ open-in-new-window renders disabled with reasonusers.conversations
canvases & listsa canvas at /client//canvases/ or in a channel’s Canvas tab: headings, paragraphs, bulleted and numbered items, checklists, quotes, code, bold, italic, links and mentionscanvases.getContent
canvases & listsa canvas’s foot: Add to this canvas → type Markdown → Enter (insert_at_end)canvases.edit
canvases & listshover 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 matchescanvases.sections.lookup, canvases.edit
canvases & listshover a block → Delete: the section deleted, or the whole document rewritten without it when no single section matchescanvases.sections.lookup, canvases.edit
canvases & listsa checklist item’s box: tick or untickcanvases.sections.lookup, canvases.edit
canvases & listscanvas ⋮ (More options) → Delete canvas → confirm, in the canvas view or the channel’s canvas tab; a Canvases page row’s ⋮ toocanvases.delete
canvases & listsa List at /client//lists/ or in a channel’s List tab: its title, the view toolbar, the table (a to-do List’s Completed circle first, the primary column bold, select pills, people chips, date tags, checkboxes, subtask counts)slackLists.items.list, slackLists.items.info
canvases & listsa 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 nextslackLists.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 & listsa 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 CompleteslackLists.items.update
canvases & listsa row’s ⋯ → Delete item; or tick rows → Delete N items → confirm; a row’s subtasks go with itslackLists.items.delete, slackLists.items.deleteMultiple
canvases & listsrecord panel → Add subtask → type → Enter (parent_item_id); the subtask shows in the panel and under its rowslackLists.items.create
canvases & listsconversation tabs: one per List conversations.info names in properties.tabs, the List insideconversations.info, slackLists.items.list
canvases & listsList header ⋯ → Rename list → SaveslackLists.update
canvases & liststhe outline left of a canvas: a line per heading → the headings → one jumps to it—
canvases & listsList 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.

AreaFeatureWhy
conversationsconversation 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 & statusEdit 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 & navigationrail 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 canvasThe twin’s files.list lists no canvas that canvases.create made, so the browser shows none.
apps & viewsa button press → the app opens a modal → its title, blocks, text inputs, Cancel and SubmitWaits 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 & viewsmodal → 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 firstWaits 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 & viewsmodal → Submit → an errors ack → each message under its input block, the modal open with what was typedWaits 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 & viewsmodal → Submit → an update ack → the modal swaps to the ack’s view, keeping what was typed into inputs it keepsWaits 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 & viewsmodal → Submit the app doesn’t answer → Slack’s generic error at the top of the modal, the inputs as typedWaits 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 openedTwin: conversations.info on an IM answers no creator, so the “invited you” suffix has nothing to read.
canvases & liststhe canvas view’s header: the canvas’s titleThe twin’s files.info answers file_not_found for a canvas, so the header says its title can’t be read.
canvases & listsrail 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 ListThe twin’s files.list lists no List that slackLists.create made, so the browser shows none.
canvases & listsList header ⋯ → Download as CSVThe 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.

AreaFeatureWhy
shellapi.testConnectivity check; no screen in the Slack client calls it.
shellrail Agents & toolsDisabled, says why on click: the twin serves no app, agent or workflow directory.
shellrail AdminDisabled, says why on click: needs admin.*, which the twin doesn’t serve.
shellrail foot moon: Turn on focus modeDisabled, 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.
shelltop bar: Chat with Slackbot AI, right of SearchDisabled, says why on click: the twin serves no Slack AI.
shelltop bar: Help (?) at the right endDisabled, says why on click: Slack’s help center and feedback aren’t part of the twin.
shellrail foot Create new (+) → HuddleDisabled, says why on click: the twin serves no huddle API.
shellrail foot Create new (+) → WorkflowDisabled, says why on click: the twin serves no workflows.
shellrail foot Create new (+) → Invite peopleDisabled, says why on click: needs admin.users.invite, which the twin doesn’t serve.
messageschat.postEphemeralNo client control sends one; showing a received one needs a client delivery the twin keeps on the control plane.
messageschat.startStreamBot token only; the client never starts a stream, and renders a streamed message as any other.
messageschat.appendStreamBot token only; no client control.
messageschat.stopStreamBot token only; no client control.
messagescomposer toolbar: More options (…), after Mention someoneDisabled, says why on click: shortcuts and workflows come from apps, which the twin doesn’t serve.
messagesmessage hover → Forward message…, between Reply in thread and Save for laterDisabled, 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.
reactionsreactions.listNo Slack client screen lists the items a person reacted to.
threadsthread pane header: Summarize threadDisabled, says why on click: the twin serves no Slack AI.
threadsthread pane header ⋮ → Get notified about new repliesDisabled, says why on click: needs subscriptions.thread.*, which the twin doesn’t serve.
threadsthread pane header ⋮ → Mark unreadDisabled, 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.
conversationschannel header: Start huddle (headphones) and More Huddles options (chevron)Disabled, says why on click: the twin serves no huddle API.
conversationschannel header bell (Edit notifications) and header ⋮ → Edit notificationsDisabled, says why on click: needs users.prefs, which the twin doesn’t serve.
conversationschannel header ⋮ → Open in new windowDisabled, says why on click: this client is a single window.
conversationsconversation tabs: + → File, canvas, or list (New)Disabled, says why on click: Slack adds the tab with conversations.addTab, which the twin doesn’t serve.
conversationsconversation tabs: + → WorkflowDisabled, says why on click: the twin serves no workflows.
conversationsconversation tabs: + → FolderDisabled, says why on click: bookmark folders aren’t part of the twin.
conversationsconversation tabs: + → Add template to channelDisabled, says why on click: the twin serves no channel templates.
conversationsconversation tabs: + → Edit tabsDisabled, says why on click: no write reorders or removes a channel’s tabs.
filesupload/v1/{ticket} (PUT)The client POSTs the bytes; PUT is the same upload over another verb.
filesrail Files → the Files sidebar → DeletedDisabled, says why on click: the twin serves no read of deleted canvases and Lists.
filesrail Files → the Files sidebar → StarredDisabled, says why on click: needs stars.list, which the twin doesn’t serve.
filesrail Files → the Files sidebar’s More optionsDisabled, says why on click: the Files sidebar’s settings aren’t part of the twin.
filesthe Canvases and Lists pages’ Templates row and See allNot drawn: the twin serves no canvas or List templates.
people & statususers.identitySign in with Slack identity endpoint; the client has no screen for it.
schedulingreminders.infoLater draws each reminder from reminders.list; no screen reads one reminder alone.
search & navigationLater → Archived tabDisabled, says why on click: archiving a Later item is Slack’s saved.* state, which the twin doesn’t serve.
search & navigationLater header → FilterDisabled, says why on click: Later’s filters read Slack’s saved.* state, which the twin doesn’t serve.
search & navigationHuddlesNo huddle API in the twin.
search & navigationsearch.allThe 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 & viewsviews.publishBot write; the twin exposes no read or event a client could render App Home from.
apps & viewsviews.updateBot write; same as views.open.
apps & viewsa message’s selects, overflow menus and date pickersDrawn disabled: the twin’s blocks.actions presses buttons only.
apps & viewsa modal’s selects, date pickers, checkboxes and radio buttonsDrawn disabled: this client draws only a modal’s text inputs so far.
apps & viewsa button inside a modalDrawn disabled: the twin’s blocks.actions presses a message’s buttons, not a modal’s.
apps & viewsa push ack → the pushed view over the open one, with BackThe 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 & viewsclosing a modal whose view sets notify_on_close → the app gets view_closedThe twin delivers no view_closed, so closing a modal tells the app nothing.
apps & viewsa modal input whose conversation picker sets response_url_enabled → response_urls in the submissionThe twin’s view_submission always carries an empty response_urls.
sidebarInvite people / Connect appsNeeds admin.*, which the twin doesn’t serve; renders disabled.
sidebarsidebar gear (Manage my sidebar)Needs users.prefs, which the twin doesn’t serve; renders disabled.
sidebarWelcome back / onboarding bannersNo backing data in the twin.
sidebarsidebar item menu → MuteNeeds users.prefs, which the twin doesn’t serve; renders disabled.
sidebarCreate a section / Move to new sectionSlack keeps sections in prefs the twin doesn’t serve; Move to new section renders disabled.
sidebarsection options: show unreads only, sortShow unreads only / sort need users.prefs; not rendered.
canvases & listsList toolbar All items ▾Disabled, says why on click: the twin serves no List views.
canvases & listsList toolbar Edit viewDisabled, says why on click: the twin serves no List views.
canvases & listsItem panel → Add CommentDisabled, says why on click: comments on an item aren’t part of the twin.
canvases & listsa standalone List’s header Share, Workflows and the info buttonNot 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.

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

  1. One workspace per container. Run one container per parallel episode; the measured footprint above says how many fit on a box.
  2. 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.
  3. Events need a clock. Without RYSTIC_PACE the clock moves only when you advance it or a request takes a simulated step, so a timed event fires when your harness says.
  4. The simulator’s own limits are on the Slack simulator page.
Last updated on