Simulators
Slack simulator
slack-twin is a local, stateful simulator of the Slack Web API for testing bots and agents. Conversations, chat, users, reactions, pins, bookmarks, files, views, canvases, lists, AI-app threads and search keep Slack’s own object shapes and its ok: false error envelope. A seeded workspace gives a bot something to read. The simulator behaves as Slack does unless a line says Differs from Slack.
The simulator never calls Slack and touches no real workspace. It accepts any token with a bot, user, app-level or workflow prefix (xoxb-, xoxp-, xapp-, xwfp-). It delivers an event only to an app you connect, once you choose how.
Quickstart
Install the CLI, sign in and boot the simulator with RYSTIC_URL pointing at it.
On Pro you sign in with an API key from your account .
curl -fsSL https://www.rystic.ai/install.sh | sh
rystic login rys_… # your API key
export RYSTIC_URL=$(rystic run slack-twin -d --port 0 --scenario design_studio --json | jq -r .data.url)
curl -s "$RYSTIC_URL/_rystic/version"
# -> {..."product":"slack-twin"...}
On Enterprise, the licensed edition signs in with a license file in place of the key: rystic login ./rystic-license.json (licensing ).
First calls
Your token acts as the scenario’s bot, studiobot. Read the newest message in #help, post a reply and read the channel back:
curl -s "$RYSTIC_URL/api/conversations.history?channel=CWDC0000004&limit=1" -H 'Authorization: Bearer xoxb-local' | jq -c '.messages[0] | {user, text: .text[0:40]}'
# -> {"user":"UWDC0000022","text":"Reminder to nobody in particular: the sh"}
curl -s -X POST "$RYSTIC_URL/api/chat.postMessage" -H 'Authorization: Bearer xoxb-local' -d channel=CWDC0000004 -d 'text=hello from the quickstart' | jq -c '{ok, channel, ts}'
# -> {"ok":true,"channel":"CWDC0000004","ts":"1772323200.000001"}
curl -s "$RYSTIC_URL/api/conversations.history?channel=CWDC0000004&limit=1" -H 'Authorization: Bearer xoxb-local' | jq -c '.messages[0] | {user, text}'
# -> {"user":"UWDCBOT0001","text":"hello from the quickstart"}
Connect your client
Point an official Slack SDK at $RYSTIC_URL/api/ with any xoxb- token, and the rest of your code stays as it is. Python:
import os
from slack_sdk import WebClient
client = WebClient(token="xoxb-local", base_url=os.environ["RYSTIC_URL"] + "/api/")
result = client.conversations_history(channel="CWDC0000001", limit=10)
Node:
const { WebClient } = require("@slack/web-api");
const client = new WebClient("xoxb-local", {
slackApiUrl: process.env.RYSTIC_URL + "/api/",
});
const result = await client.conversations.history({ channel: "CWDC0000001", limit: 10 });
For another Slack library, set its base URL as Point your client at it describes. A library left on slack.com reaches the simulator only if the intercept gateway routes it.
Differs from Slack: every method is also served at /<method>, without Slack’s /api/ prefix, so a client configured with the bare origin works too.
Keeping Slack’s hostnames
To leave your client’s own configuration alone, boot with rystic run slack-twin -d --port 0 --intercept --scenario design_studio. The simulator then sits behind its project’s HTTPS gateway. Run your client with rystic exec -- <command>, or eval "$(rystic env)" in a shell, to configure clients that honor proxy and CA environment settings. Their calls to slack.com and slack-files.com then land on the simulator, and the URLs it returns keep Slack’s hostnames. Clients that bypass the gateway are not isolated by proxy settings or --strict.
File uploads and returned URLs
The three-step upload works as it does on Slack:
files.getUploadURLExternalreturns anupload_url.- You POST the bytes to it, as a raw body or multipart, and get
OK - <bytes>. files.completeUploadExternalshares the file.
Differs from Slack: every URL the simulator returns, such as upload_url, permalink and url_private, is on the origin your request reached. Called at $RYSTIC_URL, that is the simulator’s own address, never slack.com or slack-files.com. Set RYSTIC_PRESERVE_SERVICE_URLS=1 to keep the service hosts.
A GET of url_private or url_private_download returns:
| Request | Response |
|---|---|
| with a Bearer token | the uploaded bytes, under the file’s mimetype |
| without a token | 401 |
| for a file whose bytes never reached the simulator | 404 |
An upload over 1 MiB (blob_cap_bytes in GET /_rystic/version) is accepted but not stored, so a GET of its url_private returns 404. A seeded file has no bytes either.
Each client’s quickstart was run against this simulator in each way it connects, with the vendor’s own hosts blocked.
| Client | Version | Kind | Works with | Fails with | Checked | Known gap |
|---|---|---|---|---|---|---|
slackapi/slack-cli (GitHub Releases) | v4.11.0 | cli | default host | — | 2026-11-06 | — |
slack_sdk (PyPI) | v3.45.0 | sdk | base URL, default host | — | 2026-11-06 | — |
@slack/web-api (npm) | v8.2.0 | sdk | base URL, default host | — | 2026-11-06 | — |
Auth
Send Authorization: Bearer <token>, or the token as a form-encoded body’s token= field. When a request carries both, the form token is the one read, right or wrong, as on Slack, and of two form tokens the last. Neither ?token= nor a JSON body’s token is read.
A request without a usable token returns:
| Request | api.test | Every other method |
|---|---|---|
no token, or a header under another scheme, such as Basic | ok | not_authed |
a malformed Authorization header: Bearer with nothing after it, or a second header | not_authed | not_authed |
Differs from Slack: Slack reads Bearer alone as no token, so its api.test returns ok.
Differs from Slack: Slack refuses a second Authorization header with invalid_auth.
The token’s prefix is its kind, as on Slack:
| Prefix | Kind |
|---|---|
xoxb-, or a rotated xoxe.xoxb- | bot token |
xoxp-, or a rotated xoxe.xoxp- | user token |
xapp- | app-level token |
xwfp- | workflow token |
A token with none of these prefixes, Bearer x included, returns 200 {"ok":false,"error":"invalid_auth"}.
Differs from Slack: the simulator holds no issued tokens, so it accepts whatever follows a known prefix.
A method meant for the other kind returns 200 {"ok":false,"error":"not_allowed_token_type"}:
| Token the method needs | Methods |
|---|---|
| a user token | search.*, reminders.*, users.identity, users.profile.set, users.setPhoto, users.deletePhoto |
| a bot token | chat.startStream, chat.appendStream, chat.stopStream, assistant.threads.setStatus, assistant.threads.setSuggestedPrompts, assistant.threads.setTitle, agents.sessions.setStatus, agents.sessions.rename |
The token alone names the caller only when it is bound to a user. In a loaded scenario every other token acts as the scenario’s bot (UWDCBOT0001, studiobot, in design_studio), and X-Rystic-Actor: <user id> acts as a seeded user instead (Identities).
A user’s role decides what it may delete, as on Slack. A workspace owner’s or admin’s user token deletes other people’s messages and the files it can see; anyone else’s token deletes only its own (Deleting other people’s messages and files).
An empty workspace (no --scenario) holds one bot user, U0000000001 (team T0000000001), and every other token acts as it. It holds no team record, so auth.test returns invalid_auth. Every channel is channel_not_found until you seed one or load design_studio.
API version
Slack’s Web API has no version to pin: a method is served by name, so the simulator serves the current method set at /api/<method> and at /<method>. A request that names a version in a header of its own gets the same response, with the same shapes. Response shapes follow what Slack’s API returned when the simulator was measured, and how fidelity is measured says how that is checked.
Errors and limits
Error format
A method returns a failure with HTTP 200 and {"ok":false,"error":"<code>"}, so check ok as well as the status. The one exception is the seeded 429 under Rate limits. Some refusals of an argument add response_metadata.messages, worded per method. conversations.list refuses a limit that is not a number with {"ok":false,"error":"invalid_arguments","response_metadata":{"messages":["[ERROR] input must match schema: value for limit could not be parsed as an integer"]}}. Other methods return the code alone, as conversations.members returns invalid_limit.
A refused conversations.invite also lists each user it turned down in errors, each entry carrying its own error and user. A success can carry a warning: conversations.join on a channel you are already in returns ok: true with warning and response_metadata.warnings set to already_in_channel.
Rate limits
The simulator counts no requests, so no volume of calls returns 429.
Differs from Slack: Slack limits each method’s calls per minute by its rate tier.
Two calls return ratelimited all the same:
| Call | Response |
|---|---|
reactions.list, when the user the caller acts as has "rate_limited": true in its state_user record (Seeding state) | 429 with Retry-After: 30 and {"ok":false,"error":"ratelimited"} |
users.profile.get with include_labels=true, every time | 200 {"ok":false,"error":"ratelimited"} |
Network profiles on Pro and Enterprise can reject a Slack method with 429 and the body you give it. Give the profile’s response headers what your client’s backoff reads, such as {"Retry-After": "30"}: without them, slack_sdk backs off about a second.
Pagination
The cursor methods take limit and cursor, and a page with more to come carries response_metadata.next_cursor. Pass it back as cursor until it is empty or absent. A next_cursor is the simulator’s own opaque value, so never compare it with live Slack’s. These page sizes are the simulator’s, and “(unconfirmed)” marks those not yet checked against Slack:
| Method | Default limit | Largest limit | A cursor the simulator did not issue |
|---|---|---|---|
conversations.history | 100 | 999, a larger value cut to it (unconfirmed) | invalid_cursor |
conversations.list | 100 | 1000, a larger value cut to it | invalid_cursor |
conversations.members | 100 | 1000, a larger value cut to it (unconfirmed); 0 or less returns invalid_limit | invalid_cursor |
conversations.replies | 1000 | none | invalid_cursor |
users.conversations | every conversation | 999, a larger value cut to it | invalid_cursor |
users.list | every user | none (unconfirmed) | invalid_cursor |
reactions.list | every item | none (unconfirmed) | invalid_arguments |
chat.scheduledMessages.list | 100 | none (unconfirmed) | invalid_cursor |
slackLists.items.list | every item | none (unconfirmed) | invalid_cursor |
files.info, for the file’s comments | every comment | none (unconfirmed) | invalid_arguments |
These methods page by page and count instead, and return a paging object with the page numbers and the total:
| Method | Default count | Largest count (unconfirmed) |
|---|---|---|
files.list | 100 | none |
search.messages | 20 | 100, a larger value cut to it |
search.all, search.files | 20 | none |
/_rystic/page-cap caps a method’s limit or count, so a test reaches page two without seeding a full page. Name the method without /api, as GET /conversations.list.
Idempotency
Not applicable: Slack’s Web API takes no idempotency key, so sending chat.postMessage twice posts two messages. A repeated state change is refused instead:
| Repeated call | Error |
|---|---|
reactions.add | already_reacted |
pins.add | already_pinned |
conversations.archive | already_archived |
conversations.create with a taken name | name_taken |
usergroups.create with a taken name | name_already_exists |
Coverage
The simulator covers Slack’s conversations, chat, users, reactions, pins, bookmarks, files, views, canvases, lists, reminders, user groups, search and team methods. It also serves the AI-app thread methods assistant.threads.setStatus, setSuggestedPrompts and setTitle, and agents.sessions.setStatus and rename. It serves every method to both GET and POST, with form-encoded or JSON bodies; files.getUploadURLExternal takes a form only. An unknown method returns 200 {"ok":false,"error":"unknown_method","req_method":"<name>"}. Out of scope are admin.*, Slack Connect administration, DMs with external members, Enterprise Grid and files.upload, which Slack retired. So are interactions beyond a message’s buttons and a modal’s submit: select menus, shortcuts, slash commands and views.push.
Guests and Slack Connect channels shared with another organization are served (Guests and Slack Connect), and Search has Slack’s query modifiers. The Slack methods in the published spec that the simulator leaves out are under Limitations, and a running simulator lists its own routes at GET /_rystic/routes.
448 routes, counted from the simulator’s route table: 232 listed below, and 216 more that answer the same operations at other paths.
Show all operations
| Resource | Operations |
|---|---|
| Agent session (2) | GET, POST /api/agents.sessions.rename; GET, POST /api/agents.sessions.setStatus |
| Assistant thread attributes (3) | GET, POST /api/assistant.threads.setStatus; GET, POST /api/assistant.threads.setSuggestedPrompts; GET, POST /api/assistant.threads.setTitle |
| Bookmark (3) | GET, POST /api/bookmarks.add; GET, POST /api/bookmarks.edit; GET, POST /api/bookmarks.remove |
| Canvas (6) | GET, POST /api/canvases.create; GET, POST /api/canvases.delete; GET, POST /api/canvases.edit; GET, POST /api/canvases.getContent; GET, POST /api/canvases.sections.lookup; GET, POST /api/conversations.canvases.create |
| Canvas access grant (2) | GET, POST /api/canvases.access.delete; GET, POST /api/canvases.access.set |
| Channel (conversation) (16) | GET, POST /api/bookmarks.list; GET, POST /api/chat.getPermalink; GET, POST /api/chat.postEphemeral; GET, POST /api/conversations.archive; GET, POST /api/conversations.create; GET, POST /api/conversations.history; GET, POST /api/conversations.info; GET, POST /api/conversations.list; GET, POST /api/conversations.members; GET, POST /api/conversations.open; GET, POST /api/conversations.rename; GET, POST /api/conversations.replies; GET, POST /api/conversations.setPurpose; GET, POST /api/conversations.setTopic; GET, POST /api/conversations.unarchive; GET, POST /api/pins.list |
| Channel membership (7) | GET, POST /api/conversations.close; GET, POST /api/conversations.invite; GET, POST /api/conversations.join; GET, POST /api/conversations.kick; GET, POST /api/conversations.leave; GET, POST /api/conversations.mark; GET, POST /api/users.conversations |
| Custom emoji (1) | GET, POST /api/emoji.list |
| File (6) | GET, POST /api/files.completeUploadExternal; GET, POST /api/files.delete; GET, POST /api/files.getUploadURLExternal; GET, POST /api/files.info; GET, POST /api/files.list; GET, POST /api/search.files |
| List access grant (2) | GET, POST /api/slackLists.access.delete; GET, POST /api/slackLists.access.set |
| List export job (1) | GET, POST /api/slackLists.download.start |
| List item (row) (4) | GET, POST /api/slackLists.items.create; GET, POST /api/slackLists.items.delete; GET, POST /api/slackLists.items.deleteMultiple; GET, POST /api/slackLists.items.update |
| Message (9) | GET, POST /api/chat.appendStream; GET, POST /api/chat.delete; GET, POST /api/chat.meMessage; GET, POST /api/chat.postMessage; GET, POST /api/chat.startStream; GET, POST /api/chat.stopStream; GET, POST /api/chat.update; GET, POST /api/search.all; GET, POST /api/search.messages |
| Pinned item (2) | GET, POST /api/pins.add; GET, POST /api/pins.remove |
| Reaction (4) | GET, POST /api/reactions.add; GET, POST /api/reactions.get; GET, POST /api/reactions.list; GET, POST /api/reactions.remove |
| Reminder (5) | GET, POST /api/reminders.add; GET, POST /api/reminders.complete; GET, POST /api/reminders.delete; GET, POST /api/reminders.info; GET, POST /api/reminders.list |
| Scheduled message (3) | GET, POST /api/chat.deleteScheduledMessage; GET, POST /api/chat.scheduleMessage; GET, POST /api/chat.scheduledMessages.list |
| Slack List (5) | GET, POST /api/slackLists.create; GET, POST /api/slackLists.download.get; GET, POST /api/slackLists.items.info; GET, POST /api/slackLists.items.list; GET, POST /api/slackLists.update |
| Team (workspace) (2) | GET, POST /api/team.info; GET, POST /api/team.profile.get |
| User (14) | GET, POST /api/auth.test; GET, POST /api/dnd.info; GET, POST /api/dnd.teamInfo; GET, POST /api/users.deletePhoto; GET, POST /api/users.getPresence; GET, POST /api/users.identity; GET, POST /api/users.info; GET, POST /api/users.list; GET, POST /api/users.lookupByEmail; GET, POST /api/users.profile.get; GET, POST /api/users.profile.set; GET, POST /api/users.setActive; GET, POST /api/users.setPhoto; GET, POST /api/users.setPresence |
| User Group (7) | GET, POST /api/usergroups.create; GET, POST /api/usergroups.disable; GET, POST /api/usergroups.enable; GET, POST /api/usergroups.list; GET, POST /api/usergroups.update; GET, POST /api/usergroups.users.list; GET, POST /api/usergroups.users.update |
| View (modal/home) (3) | GET, POST /api/views.open; GET, POST /api/views.publish; GET, POST /api/views.update |
| Other (1) | GET, POST /api/api.test |
Also served, outside the model: POST /actions/{token}, POST /api/apps.connections.open (with SLACK_TWIN_SOCKET_MODE=1), POST /api/assistant.threads.open, POST /api/assistant.threads.setContext, POST /api/blocks.actions, POST /api/views.submit, POST /apps.connections.open (with SLACK_TWIN_SOCKET_MODE=1), POST /assistant.threads.open, POST /assistant.threads.setContext, POST /blocks.actions, GET /files-pri/{tf}/download/{name}, GET /files-pri/{tf}/{name}, GET /img/avatars/{name}, POST /upload/v1/{ticket}, PUT /upload/v1/{ticket}, POST /views.submit.
Scenarios
The simulator ships 1 scenario; the quickstart boots design_studio.
| Scenario | What it is for | What it seeds | Clock |
|---|---|---|---|
design_studio (default) | a whole web design studio’s Slack a year in | bookmark 74, channel 60, connected_team 2, file 48, membership 451, message 2958, pin 45, reaction 324, scheduled_message 4, team 1, user 47 | 2026-03-01T00:00:00Z |
What each scenario holds:
design_studio:- Sixty conversations — five company channels, five craft channels, ten live client projects, two Slack Connect channels shared with clients, four private channels, five DMs, a group DM and twenty-eight archived projects — and 2,958 messages from a cast of forty-five, with the pins, bookmarks, files, threads and queued messages a year leaves behind.
- The token is studiobot, and every question the workspace asks it — who is on call, when a client launches, how a site is rolled back — is answered by a record inside this binary.
Boot one by name, or load one into a running simulator:
rystic run slack-twin -d --port 0 --scenario design_studio
curl -s -X POST "$RYSTIC_URL/_rystic/scenario" -d '{"name":"design_studio"}'
What the scenarios do not hold
design_studiois the only scenario. Its token acts asstudiobot(UWDCBOT0001).#generalisCWDC0000001and#helpisCWDC0000004.- Without
--scenario, the simulator boots an empty workspace with no team and one bot user, andPOST /_rystic/resetreturns to it. Seed the workspace you need (Seeding state). - The refund and ops scenarios of earlier releases are not shipped; rebuild such a workspace with
POST /_rystic/seed.
Seeding state
Each family of records is one register named state_<family>, such as state_team, state_user, state_channel or state_message. A register holds Slack’s own object shapes, each filed under a key. Most are keyed by id. Some are not, such as these:
| Register | Key |
|---|---|
state_message | channel + ts |
state_membership | channel + user |
state_reaction | channel + timestamp + name: one record per emoji, carrying the reacting users and their count |
Membership is its own register. A channel with messages but no state_membership row returns not_in_channel to every reader, as on Slack. Seed a workspace with one user, one channel and one message, then read it back:
curl -s -X POST "$RYSTIC_URL/_rystic/reset" -o /dev/null -w '%{http_code}\n'
# -> 204
curl -s -X POST "$RYSTIC_URL/_rystic/seed" -o /dev/null -w '%{http_code}\n' -d '{"state_team":{"T0SEED":{"id":"T0SEED","name":"Seed Workspace","domain":"seed"}},"state_user":{"member":{"id":"U0SEED001","name":"seeduser","real_name":"Seed User","team_id":"T0SEED"}},"state_channel":{"C0SEED001":{"id":"C0SEED001","name":"seeded","is_channel":true,"is_archived":false,"created":1767225600,"creator":"U0SEED001"}},"state_membership":{"C0SEED001/U0SEED001":{"channel":"C0SEED001","user":"U0SEED001"}},"state_message":{"C0SEED001/1767225600.000100":{"channel":"C0SEED001","ts":"1767225600.000100","type":"message","text":"seeded hello","user":"U0SEED001"}}}'
# -> 204
curl -s "$RYSTIC_URL/api/conversations.history?channel=C0SEED001" -H 'Authorization: Bearer xoxb-local' | jq -c '{ok, messages: [.messages[] | {text, user}]}'
# -> {"ok":true,"messages":[{"text":"seeded hello","user":"U0SEED001"}]}
curl -s "$RYSTIC_URL/api/auth.test" -H 'Authorization: Bearer xoxb-local' | jq -c '{team_id, user_id}'
# -> {"team_id":"T0SEED","user_id":"U0SEED001"}
Key the user your app acts as under member, as this seed keys U0SEED001: every token then acts as it. Keyed under its id alone, the user joins the empty workspace’s bot, U0000000001, which every token still acts as. views.open needs a seeded state_trigger for the trigger_id it names.
The simulator accepts any JSON-object record in a known register without checking its fields.
GET /_rystic/inspect keeps actor-label aliases such as member, so a dump posted back after a reset restores who each caller acts as; ?view=identities, a display projection that folds them, is no backup.
Scenarios and seeding covers the seed response, refusals and boot files.
Webhooks
The simulator renders an event for each Web API call that causes one, and delivers it to your app only when you choose a mode. Events API has the full event table, retries and the ways delivery differs from Slack.
- Delivery switch.
--env SLACK_TWIN_SOCKET_MODE=1serves Socket Mode:apps.connections.openreturns a one-timews://URL on the simulator.--env SLACK_TWIN_EVENTS_URL=<your URL>posts eachevent_callbackto a request URL instead, and needs--env SLACK_TWIN_SIGNING_SECRET=<secret>beside it: the simulator refuses to start without one. With neither mode set, nothing is delivered. - Signing. A request-URL delivery is signed with
SLACK_TWIN_SIGNING_SECRET, so Bolt’s verification passes. Delivery starts with Slack’s signedurl_verificationrequest, and your app replies with itschallenge. - Retries. An event your app does not ack within 3 seconds is redelivered after 0 seconds, 1 minute and 5 minutes. A Socket Mode envelope carries
retry_attemptandretry_reason, and a request-URL delivery thex-slack-retry-numandx-slack-retry-reasonheaders. A non-2xx response carryingx-slack-no-retry: 1ends the retries. - The log.
GET /_rystic/egressrecords every event your calls render, delivered or not, andGET /_rystic/settlewaits until your bot’s turn is over. - Interactions. A person’s button press or modal submit reaches your app as
block_actionsorview_submission(Interactivity).
Features
Each feature has its own page:
- AI-app threads: Set an AI app thread’s status, title and suggested prompts as Bolt’s Assistant class does, and open the app’s container as a person so your app hears assistant_thread_started.
- Bot turns: GET /_rystic/settle tells your driver when your bot’s turn is over, and the call log shows what the bot did in it.
- Events API: Slack’s Events API for your bot over Socket Mode or a request URL, with retries and app mentions.
- Guests and Slack Connect: Guests whose own tokens see only their channels and the people in them, and Slack Connect channels shared with members of another organization, as a bot reads them.
- Identities: Several agents share one workspace, each under its own token, acting as a seeded user, with a call log for grading.
- Interactivity: A person presses a button on your bot’s message or submits its modal, and your app receives Slack’s block_actions or view_submission payload.
- MCP server: The unmodified korotovsky Slack MCP server runs against the simulator through four tools: channel discovery, history, thread replies and posting a reply.
- Search: search.messages, search.all and search.files with Slack’s query modifiers, checking access for messages; files are searched by text, and only a guest’s search.files is limited to its own files and its conversations’ files.
- Verified UI tasks: Seed a task’s workspace and timed events, reset between episodes, grade what the agent did and size how many environments fit on one box.
- Verified UI: A Slack client that runs on the simulator’s Web API, run beside the simulator or as one container per task.
- Workspace UI: A browser view of the workspace at /_rystic/ui that draws the simulator’s state as a Slack client and redraws as your bot calls the API.
Determinism and reset
A message’s ts is both its id and its timestamp, and it comes from the simulator’s clock, so pin the clock before a run you want byte-identical.
Schedule relative to the scenario’s clock: a chat.scheduleMessage post_at from your wall clock is more than 120 days ahead of design_studio and returns time_too_far. post_at=1772326800, an hour past the scenario’s clock (1772323200), is accepted. An empty workspace’s clock starts at boot time (initial_clock in GET /_rystic/version).
Reset clears the token bindings. The clock moves:
- a scheduled message, posted once when the clock reaches its
post_at(Events API for the events it sends); - a request-URL redelivery, which also runs on the wall clock;
- an interaction’s
trigger_id(3 seconds) andresponse_url(30 minutes) (Interactivity); - a timeline event a scenario file or
POST /_rystic/injectqueues (Verified UI tasks).
Reset and determinism covers reset, the clock and the RNG.
Limitations
Known issues.
assistant_thread_startedandassistant_thread_context_changed, whichassistant.threads.openandassistant.threads.setContextsend, follow Slack’s documented examples. They were never measured against Slack: the Slack app the simulator is measured with has no Agent experience, which cannot be turned off once on.
Beyond the known issues, 246 of the 354 operations in the published spec are not served, 1 success response may leave out fields the spec requires, 763 documented responses have no test yet and 132 behaviors are not modeled. The details follow.
Show all limitations
Not served. 246 of the 354 operations in the published spec are not served.
- agents, 2 of 11 served:
POST /api/agents.conversations.archive,POST /api/agents.conversations.create,POST /api/agents.conversations.getCanvas,POST /api/agents.conversations.listViews,POST /api/agents.conversations.removeView,POST /api/agents.conversations.setCanvasContent,POST /api/agents.conversations.setCommands,POST /api/agents.conversations.setProperties, … and 1 more - assistant, 3 of 5 served:
POST /api/assistant.search.context,POST /api/assistant.search.info - auth, 1 of 3 served:
GET /api/auth.revoke,POST /api/auth.teams.list - chat, 12 of 13 served:
POST /api/chat.unfurl - conversations, 19 of 28 served:
POST /api/conversations.acceptSharedInvite,POST /api/conversations.approveSharedInvite,GET /api/conversations.declineSharedInvite,POST /api/conversations.externalInvitePermissions.set,POST /api/conversations.inviteShared,POST /api/conversations.listConnectInvites,POST /api/conversations.requestSharedInvite.approve,POST /api/conversations.requestSharedInvite.deny, … and 1 more - dnd, 2 of 5 served:
POST /api/dnd.endDnd,POST /api/dnd.endSnooze,POST /api/dnd.setSnooze - files, 5 of 15 served:
POST /api/files.comments.delete,POST /api/files.remote.add,GET /api/files.remote.info,GET /api/files.remote.list,POST /api/files.remote.remove,GET /api/files.remote.share,POST /api/files.remote.update,POST /api/files.revokePublicURL, … and 2 more - team, 2 of 9 served:
GET /api/team.accessLogs,GET /api/team.billableInfo,POST /api/team.billing.info,POST /api/team.externalTeams.disconnect,GET /api/team.externalTeams.list,GET /api/team.integrationLogs,POST /api/team.preferences.list - users, 12 of 13 served:
POST /api/users.discoverableContacts.lookup - views, 3 of 4 served:
POST /api/views.push - Not served at all: admin (128), apps (23), blocks (1), bots (1), calls (6), dialog (1), entity (3), functions (8), migration (1), oauth (6), openid (2), rtm (2), slackbot (7), stars (3), tooling (1), workflows (8)
Missing response fields. The spec requires these fields in a success response, and the simulator may leave them out:
GET, POST /api/conversations.leave(200):error
Not tested. 884 tests compare the simulator with the real service. 763 documented responses have no test yet, and 227 of them need a credential our test account doesn’t hold (200 ×3, 401 ×74, 403 ×148, 404 ×2). The other 536:
| Operation | Status | Why |
|---|---|---|
GET, POST /api/agents.sessions.rename | 200 | a well-formed request does not provoke it (3 cases) |
GET, POST /api/agents.sessions.rename | 200 | no known request reaches it |
GET, POST /api/agents.sessions.rename | 429 | no request a client sends provokes it |
GET, POST /api/agents.sessions.setStatus | 200 | a well-formed request does not provoke it (2 cases) |
GET, POST /api/agents.sessions.setStatus | 200 | no known request reaches it |
GET, POST /api/agents.sessions.setStatus | 429 | no request a client sends provokes it |
GET, POST /api/api.test | 200 | a well-formed request does not provoke it (2 cases) |
GET, POST /api/api.test | 429 | no request a client sends provokes it |
GET, POST /api/assistant.threads.setStatus | 200 | a well-formed request does not provoke it (2 cases) |
GET, POST /api/assistant.threads.setStatus | 429 | no request a client sends provokes it |
GET, POST /api/assistant.threads.setSuggestedPrompts | 200 | a well-formed request does not provoke it (2 cases) |
GET, POST /api/assistant.threads.setSuggestedPrompts | 200 | no known request reaches it (2 cases) |
GET, POST /api/assistant.threads.setTitle | 200 | a well-formed request does not provoke it (2 cases) |
GET, POST /api/assistant.threads.setTitle | 200 | no known request reaches it (2 cases) |
GET, POST /api/assistant.threads.setTitle | 429 | no request a client sends provokes it |
GET, POST /api/auth.test | 200 | a well-formed request does not provoke it (2 cases) |
GET, POST /api/auth.test | 429 | no request a client sends provokes it |
GET, POST /api/bookmarks.add | 200 | a well-formed request does not provoke it (3 cases) |
GET, POST /api/bookmarks.add | 200 | no known request reaches it (2 cases) |
GET, POST /api/bookmarks.add | 200 | a test against the service contradicted the documentation |
| … and 271 more |
Not modeled. Behavior the simulator does not model, by operation:
- feature_disabled (the agent tasks feature is off for the workspace) and no_permission: documented in x-slack-errors but never observed; the workspace answers not_authorized instead. (
GET, POST /api/agents.sessions.rename) - icon_emoji, icon_url and username are documented as cleared by passing null; the clearing semantics are unmeasured since no success is reachable. (
GET, POST /api/agents.sessions.setStatus) - No in-scope read returns the stored status, loading_messages, icon or username, so the write is unverifiable by readback; only the ok/error envelope is measured. (
GET, POST /api/assistant.threads.setStatus) - The status’s documented two-minute timeout (the service removes the status if no message is sent) is not modelled: no trace measures the expiry. (
GET, POST /api/assistant.threads.setStatus) - 429 rate-limited response (Retry-After header): the spec declares the status with no body and no capture shows one, so no branch is declared. (
GET, POST /api/assistant.threads.setSuggestedPrompts) - No in-scope read returns the stored prompts, so the write is unverifiable by read-back; its effect rests on the operation’s documented purpose. (
GET, POST /api/assistant.threads.setSuggestedPrompts) - invalid_charset / invalid_post_type / missing_post_type / invalid_form_data: service-wide encoding refusals not captured on this method. (
GET, POST /api/assistant.threads.setSuggestedPrompts) - missing_thread_ts: in the error enum but never observed — the thread_ts-less call answered not_agent_app on every capture. (
GET, POST /api/assistant.threads.setSuggestedPrompts) - static_prompts_configured: documented for a thread_ts-less call by an agent app with static prompts configured; unreachable for the capture app, which answers not_agent_app first. (
GET, POST /api/assistant.threads.setSuggestedPrompts) - No in-scope read returns the stored title, so the write is unverifiable by read-back; only the ok/error answer is measured. (
GET, POST /api/assistant.threads.setTitle) - Whether a title has a maximum length at all is unknown: 300 characters answered ok and no larger value was sent. (
GET, POST /api/assistant.threads.setTitle) - invalid_form_data (a body mislabelled as form data) is listed in x-slack-errors but no observation on this method isolates it, so no branch is declared for it. (
GET, POST /api/assistant.threads.setTitle) - invalid_bookmark_type: bookmarks.edit exposes no
typeargument, so no request shape in scope reaches this error. (GET, POST /api/bookmarks.edit) - Whether the service accepts channel_ids and user_ids together, or only exactly one of them: invalid_parameters says ‘One of user_ids and channel_ids must be defined’ without stating that both is a failure. (
GET, POST /api/canvases.access.delete) - Permission, plan and enterprise refusals (access_denied, no_permission, restricted_action, canvas_disabled_user_team, enterprise_is_restricted, team_access_not_granted, ekm_access_denied, org_login_required) are listed in x-slack-errors with no condition that distinguishes them on this method. (
GET, POST /api/canvases.access.set) - The 1..20 bound on channel_ids / user_ids has no documented error code. (
GET, POST /api/canvases.access.set) - The access_level value space (read/write/owner) is documented in prose, but the error a rejected value produces (invalid_arguments?) is not stated, so no branch is declared for it. (
GET, POST /api/canvases.access.set) - Whether every listed channel or user must resolve before any grant is written (all-or-nothing) or the method writes what it can and lists the rest in failed_to_update_*_ids is not documented. (
GET, POST /api/canvases.access.set) - failed_to_update_user_ids / failed_to_update_channel_ids appear both as success-envelope lists (partial failure) and as a documented error code; which conditions produce the ok-with-failures form versus the error form is not documented, so only the ok form is modeled. (
GET, POST /api/canvases.access.set) - The platform-wide failure codes the error enum lists but no behaviour here distinguishes (access_denied, accesslimited, account_inactive, deprecated_endpoint, ekm_access_denied, enterprise_is_restricted, fatal_error, internal_error, invalid_arg_name, invalid_arguments, invalid_array_arg, invalid_charset, invalid_post_type, method_deprecated, missing_post_type, no_permission, org_login_required, request_timeout, restricted_action, service_unavailable, team_access_not_granted, team_added_to_org, token_expired, token_revoked, two_factor_setup_required). (
GET, POST /api/canvases.create) - … and 112 more
By design.
- Fidelity is measured, not total. A response field with no live example may differ from Slack’s. Assert on the fields your bot reads, and never byte-compare
tsagainst live Slack. - One workspace per process. Your token acts in one team; the other organizations in its Slack Connect channels are records your bot reads, not workspaces a token acts in. Run one isolated workspace per episode; concurrent episodes need separate processes or containers.
- Little moves without a request or a clock move. Presence changes only through explicit calls or seeded state, and ephemeral messages are transient activity for the workspace UI, not durable history or Events API deliveries.
- Events and interactions are opt-in. Nothing is delivered until a run chooses a mode; assert on state through the Web API or
/_rystic/inspectwhen you do not need delivery. - Token kinds are enforced, not sourced. The prefix decides what a token may call.
views.opentakes only thetrigger_idof a seededstate_trigger, and the one a press delivers returnsexpired_trigger_id.users.setActiveis a no-op.
Release notes: Changelog