Simulators
Events API
The simulator delivers Events API events for the Web API calls it serves, so a bot built on Bolt or the raw protocol hears what happens in the workspace. Delivery is off until you choose a mode, but the simulator records every event its calls render either way, and GET /_rystic/settle tells you when your bot’s turn is over. Post a message that mentions the bot as a person, wait for the simulator to settle, then read the events it rendered:
curl -s -X POST "$RYSTIC_URL/_rystic/scenario" -d '{"name":"design_studio"}' -o /dev/null
curl -s "$RYSTIC_URL/_rystic/settle?quiet_ms=100" | jq -c '{settled, last_call_seq}'
# -> {"settled":true,"last_call_seq":0}
curl -s -X POST "$RYSTIC_URL/api/chat.postMessage" -H 'Authorization: Bearer xoxp-anything' -H 'X-Rystic-Actor: UWDC0000020' -H 'X-Rystic-Surface: client' -d channel=CWDC0000004 --data-urlencode 'text=<@UWDCBOT0001> draft a reply' | jq -c '{ok, ts}'
# -> {"ok":true,"ts":"1772323200.000001"}
curl -s "$RYSTIC_URL/_rystic/settle?quiet_ms=100" | jq -c '{settled, last_call_seq}'
# -> {"settled":true,"last_call_seq":1}
curl -s "$RYSTIC_URL/_rystic/egress" | jq -c '[.events[] | .type]'
# -> ["message","app_mention"]
Socket Mode
Choose one mode per run with --env; with neither set, nothing is delivered. In Socket Mode, apps.connections.open takes an app-level token (xapp-…) and answers with a one-time ws:// URL on the simulator. Boot with the switch and point Bolt’s API base at the simulator:
rystic run slack-twin -d --name slack --port 8080 --scenario design_studio --env SLACK_TWIN_SOCKET_MODE=1
from slack_bolt import App
from slack_bolt.adapter.socket_mode import SocketModeHandler
from slack_sdk import WebClient
app = App(client=WebClient(token="xoxb-local", base_url="http://localhost:8080/api/"))
@app.event("app_mention")
def mention(event, say):
say(f"hi <@{event['user']}>")
SocketModeHandler(app, "xapp-local").start()
With SLACK_CLIENT_ID and SLACK_CLIENT_SECRET in its environment, Bolt expects an OAuth install and drops every event; unset them for a run against the simulator.
Each event arrives as an events_api envelope. An envelope that is not acked within 3 seconds is redelivered with retry_attempt and retry_reason: "timeout" after 0 seconds, 1 minute and 5 minutes, as on Slack. SLACK_TWIN_SOCKET_MODE=1 turns request-URL delivery off, as enabling Socket Mode does on Slack.
Apps and app ids
Each app gets its own copy of every event. A token shaped like Slack’s, xapp-1-<app id>-… with an app id such as A0123456789, connects as that app, and its envelopes carry that api_app_id; any other token, such as xapp-local, connects as the simulator’s one default app, AWDCBOT0001. An app’s connections share its events, one connection per envelope, as on Slack. An app starts receiving events at its first apps.connections.open and keeps receiving them until the simulator stops, and a reset keeps it. While none of its connections is open, an event keeps to the redelivery schedule and is dropped when the schedule ends. The verified UI connects as its own app, AUIRYSTIC01. To run two bots, or a bot beside the verified UI, give each its own app id, for example xapp-1-A0000000001-1-x and xapp-1-A0000000002-1-x.
Envelopes name team TWDC0000001, app AWDCBOT0001 (the default app’s) and bot user UWDCBOT0001, the design_studio bot. Set SLACK_TWIN_TEAM_ID, SLACK_TWIN_APP_ID, SLACK_TWIN_BOT_USER_ID and SLACK_TWIN_VERIFICATION_TOKEN to match another scenario.
Request URL
The simulator POSTs each event_callback to your URL, signed with your signing secret, so Bolt’s request verification passes. A request URL needs SLACK_TWIN_SIGNING_SECRET; without it the simulator refuses to start:
rystic run slack-twin -d --name slack --port 8080 --scenario design_studio \
--env SLACK_TWIN_EVENTS_URL=http://localhost:3000/slack/events \
--env SLACK_TWIN_SIGNING_SECRET=my-signing-secret # your app's signing secret
Before its first event the simulator sends your URL Slack’s signed url_verification request, as Slack does when you save a request URL in your app’s settings: {"token":…,"challenge":…,"type":"url_verification"}. Answer HTTP 200 with the challenge, as text, as JSON {"challenge":…} or form-encoded; Bolt does this for you. Until an answer passes, events wait, and GET /_rystic/egress says why under delivery.handshake (status and reason). The simulator sends it once at startup and, while it has not passed, again before the next event; a reset or a scenario load keeps a passed one. Slack sends none when apps.manifest.update sets the URL, and SLACK_TWIN_URL_VERIFICATION=off stands for that app. The interactivity URL is never verified, as on Slack.
Your app acks an event with a 2xx within 3 seconds. A delivery that is not acked is retried after 0 seconds, 1 minute and 5 minutes with the same body and a fresh signature made when it is sent. Each retry carries the headers x-slack-retry-num and x-slack-retry-reason. A non-2xx answer carrying x-slack-no-retry: 1 ends that event’s retries, as on Slack.
A simulator running in a container reaches your machine at host.docker.internal, not localhost. In either mode, a URL on a Slack host inside an event is on the origin of your latest Web API call, as the Web API answers it, unless RYSTIC_PRESERVE_SERVICE_URLS=1 keeps the service hosts.
Which calls send which events
| Call | Events |
|---|---|
chat.postMessage | message (thread_broadcast for a broadcast reply), then app_mention when the text mentions a bot user |
a chat.scheduleMessage message falling due | the same events as chat.postMessage, when the simulator clock reaches its post_at |
chat.update | message / message_changed |
chat.delete | message / message_deleted |
conversations.create | channel_created (public channels only), member_joined_channel, message / channel_join |
conversations.invite | member_joined_channel and message / channel_join per user added |
conversations.leave | member_left_channel |
conversations.rename | channel_rename (public channels only) |
conversations.archive | channel_archive (public channels only) |
files.completeUploadExternal with channel_id | file_shared, then message / file_share |
pins.add | pin_added |
reactions.add, reactions.remove | reaction_added, reaction_removed |
A post made as a person with X-Rystic-Surface: client arrives as message and app_mention events with a client_msg_id and no bot_id, app_id or bot_profile, as a message typed in the Slack client does, so an app that ignores bot messages starts a turn on it. So does a message the person scheduled with the header, when it falls due. A post or schedule without the header keeps its app attribution, as on Slack.
A member leaving
The simulator serves no admin.users.*, so a test deactivates a member with a seed, {"state_user": {"<id>": {"deleted": true}}} posted to /_rystic/seed or as a scenario timeline seed event. Every app then receives one user_change with the whole user and deleted: true, and so it does for a reactivation or for a seed that changes another field of the user object, such as profile or real_name; a change to presence alone sends none.
As on Slack, a deactivated member leaves every public and private channel but keeps its DMs and stays in users.list with deleted: true. Both conversations.members and num_members exclude that member, and a reactivation adds it back to no channel. A boot seed or a scenario load sends nothing.
Where delivery differs from Slack
- Events from every conversation are delivered, not only those the bot is in, and
app_mentionfires for a mention of any bot user. - Request-URL delivery follows no redirect, so a 3xx answer is a failed attempt, where Slack follows two. Slack refuses to save a URL that fails
url_verification, so it sends no event there at all; the simulator holds the events and sends them once the URL passes. - A request-URL retry runs on the wall clock, and moving the simulator’s clock past its due time with
/_rystic/clocksends it at once. Socket Mode redeliveries run on the wall clock alone. - Not delivered:
message/channel_nameandmessage/channel_archive, the hiddenmessage_changedSlack sends after a broadcast reply,user_changeforusers.profile.setandusers.setPhoto, and events for private-channel renames and archives. - A reaction event names the emoji as your call sent it; Slack reports some aliases by their canonical name (
thumbsuparrives as+1). - A seed that changes only a profile’s custom
fieldssendsuser_change, where Slack sends none. A deactivated member leaves its channels without amember_left_channelevent.
Waiting for your bot’s turn
After your driver acts, your bot’s turn is several events, acks and Web API calls, and only the simulator sees when it ends. GET /_rystic/settle answers "settled":true once no event or interaction awaits your bot’s ack (in request URL mode, its 2xx) and no Web API call has landed for quiet_ms of wall time (default 500). At timeout_ms (default 30000; both at most 60000) it answers "settled":false with pending, what is still open.
Calls made with X-Rystic-Surface: client, from the state UI or by a scheduled event never count, so your driver’s own post does not. principal=UWDCBOT0001 watches only calls made as that user. A redelivery due later never holds it and is listed in scheduled.
last_call_seq is the last seq in /_rystic/calls. Settle before you act and again after, and the calls between the two seqs are the bot’s turn. Quiet is wall time, not the simulator’s clock, because your bot runs in real time: set quiet_ms above the longest pause inside one turn, such as a model call. rystic settle <instance> and the MCP server’s settle tool ask the same route.