# Slack simulator

A local Slack workspace for Slack clients: conversations, chat, users, reactions, pins, bookmarks, file uploads and views, with bot and user tokens.

`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

<!-- BEGIN generated: quickstart (go run ./ingest/cmd/twindocs slack; do not edit) -->

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](https://www.rystic.ai/account/api-keys).

```bash
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](https://www.rystic.ai/docs/licensing)).

<!-- END generated: quickstart -->

### 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:

```bash
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:

```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:

```js
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](https://www.rystic.ai/docs/installation.md#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:

1. `files.getUploadURLExternal` returns an `upload_url`.
2. You POST the bytes to it, as a raw body or multipart, and get `OK - <bytes>`.
3. `files.completeUploadExternal` shares 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.

<!-- BEGIN generated: known-clients (go run ./ingest/cmd/twindocs slack; do not edit) -->

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

<!-- END generated: known-clients -->

## 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](https://www.rystic.ai/docs/simulators/slack/features/identities.md)).

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](https://www.rystic.ai/docs/simulators/slack/features/identities.md#deleting-other-peoples-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](https://www.rystic.ai/docs/fidelity.md) 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](#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](#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](https://www.rystic.ai/docs/network-profiles.md) 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`](https://www.rystic.ai/docs/control-plane.md#routes-by-plan) 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](https://www.rystic.ai/docs/simulators/slack/features/ai-app-threads.md) 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](https://www.rystic.ai/docs/simulators/slack/features/guests-and-connect.md)), and [Search](https://www.rystic.ai/docs/simulators/slack/features/search.md) 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`.

<!-- BEGIN generated: served-operations (go run ./ingest/cmd/twindocs slack; do not edit) -->

<!-- coverage-claim -->
448 routes, counted from the simulator's route table: 232 listed below, and 216 more that answer the same operations at other paths.

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

<!-- END generated: served-operations -->

## Scenarios

<!-- BEGIN generated: scenarios (go run ./ingest/cmd/twindocs slack; do not edit) -->

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:

```bash
rystic run slack-twin -d --port 0 --scenario design_studio
curl -s -X POST "$RYSTIC_URL/_rystic/scenario" -d '{"name":"design_studio"}'
```

<!-- END generated: scenarios -->

### What the scenarios do not hold

- `design_studio` is the only scenario. Its token acts as `studiobot` (`UWDCBOT0001`). `#general` is `CWDC0000001` and `#help` is `CWDC0000004`.
- Without `--scenario`, the simulator boots an empty workspace with no team and one bot user, and `POST /_rystic/reset` returns to it. Seed the workspace you need ([Seeding state](#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:

```bash
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](https://www.rystic.ai/docs/concepts/worlds.md) 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](https://www.rystic.ai/docs/simulators/slack/features/events-api.md) has the full event table, retries and the ways delivery differs from Slack.

- **Delivery switch.** `--env SLACK_TWIN_SOCKET_MODE=1` serves Socket Mode: `apps.connections.open` returns a one-time `ws://` URL on the simulator. `--env SLACK_TWIN_EVENTS_URL=<your URL>` posts each `event_callback` to 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 signed `url_verification` request, and your app replies with its `challenge`.
- **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_attempt` and `retry_reason`, and a request-URL delivery the `x-slack-retry-num` and `x-slack-retry-reason` headers. A non-2xx response carrying `x-slack-no-retry: 1` ends the retries.
- **The log.** `GET /_rystic/egress` records every event your calls render, delivered or not, and `GET /_rystic/settle` waits until your bot's turn is over.
- **Interactions.** A person's button press or modal submit reaches your app as `block_actions` or `view_submission` ([Interactivity](https://www.rystic.ai/docs/simulators/slack/features/interactivity.md)).

## Features

<!-- BEGIN generated: features (go run ./ingest/cmd/twindocs slack; do not edit) -->

Each feature has its own page:

- [AI-app threads](https://www.rystic.ai/docs/simulators/slack/features/ai-app-threads.md): 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](https://www.rystic.ai/docs/simulators/slack/features/bot-turns.md): 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](https://www.rystic.ai/docs/simulators/slack/features/events-api.md): Slack's Events API for your bot over Socket Mode or a request URL, with retries and app mentions.
- [Guests and Slack Connect](https://www.rystic.ai/docs/simulators/slack/features/guests-and-connect.md): 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](https://www.rystic.ai/docs/simulators/slack/features/identities.md): Several agents share one workspace, each under its own token, acting as a seeded user, with a call log for grading.
- [Interactivity](https://www.rystic.ai/docs/simulators/slack/features/interactivity.md): 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](https://www.rystic.ai/docs/simulators/slack/features/mcp.md): The unmodified korotovsky Slack MCP server runs against the simulator through four tools: channel discovery, history, thread replies and posting a reply.
- [Search](https://www.rystic.ai/docs/simulators/slack/features/search.md): 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](https://www.rystic.ai/docs/simulators/slack/features/verified-ui-tasks.md): 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](https://www.rystic.ai/docs/simulators/slack/features/verified-ui.md): A Slack client that runs on the simulator's Web API, run beside the simulator or as one container per task.
- [Workspace UI](https://www.rystic.ai/docs/simulators/slack/features/workspace-ui.md): 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.

<!-- END generated: features -->

## 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](https://www.rystic.ai/docs/simulators/slack/features/events-api.md) for the events it sends);
- a request-URL redelivery, which also runs on the wall clock;
- an interaction's `trigger_id` (3 seconds) and `response_url` (30 minutes) ([Interactivity](https://www.rystic.ai/docs/simulators/slack/features/interactivity.md));
- a timeline event a scenario file or `POST /_rystic/inject` queues ([Verified UI tasks](https://www.rystic.ai/docs/simulators/slack/features/verified-ui-tasks.md)).

[Reset and determinism](https://www.rystic.ai/docs/concepts/reset-and-determinism.md) covers reset, the clock and the RNG.

## Limitations

<!-- BEGIN generated: known-gaps (go run ./ingest/cmd/twindocs slack; do not edit) -->

**Known issues.**

- `assistant_thread_started` and `assistant_thread_context_changed`, which `assistant.threads.open` and `assistant.threads.setContext` send, 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.

<!-- gaps-claim -->
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.

**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 `type` argument, 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

<!-- END generated: known-gaps -->

**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 `ts` against 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/inspect` when you do not need delivery.
- **Token kinds are enforced, not sourced.** The prefix decides what a token may call. `views.open` takes only the `trigger_id` of a seeded `state_trigger`, and the one a press delivers returns `expired_trigger_id`. `users.setActive` is a no-op.

Release notes: [Changelog](https://www.rystic.ai/docs/simulators/slack/changelog.md)

---

Source: https://www.rystic.ai/docs/simulators/slack · Markdown: https://www.rystic.ai/docs/simulators/slack.md
