Simulators
Identities
Identities let several agents or test drivers share one workspace, each under its own token and acting as its own user. Bind a token to a user, and every call with that token acts as that user. Pass X-Rystic-Actor for one call, or X-Rystic-Surface: client to post the way a person types in Slack:
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-pm-agent":"UWDC0000001","xoxp-worker-agent":"UWDC0000002"}}' -o /dev/null -w '%{http_code}\n'
# -> 204
curl -s "$RYSTIC_URL/api/auth.test" -H 'Authorization: Bearer xoxp-worker-agent' | jq -c '{user, user_id}'
# -> {"user":"noor","user_id":"UWDC0000002"}
curl -s "$RYSTIC_URL/api/auth.test" -H 'Authorization: Bearer xoxb-local' -H 'X-Rystic-Actor: UWDC0000001' | jq -c '{user, user_id}'
# -> {"user":"dana","user_id":"UWDC0000001"}
curl -s -X POST "$RYSTIC_URL/api/chat.postMessage" -H 'Authorization: Bearer xoxp-worker-agent' -d channel=CWDC0000004 -d 'text=on it' | jq -c '{ok, user: .message.user, bot_id: .message.bot_id}'
# -> {"ok":true,"user":"UWDC0000002","bot_id":"B000000001"}
curl -s -X POST "$RYSTIC_URL/api/chat.postMessage" -H 'Authorization: Bearer xoxp-worker-agent' -H 'X-Rystic-Surface: client' -d channel=CWDC0000004 -d 'text=on it, typed by hand' | jq -c '{ok, user: .message.user, bot_id: .message.bot_id}'
# -> {"ok":true,"user":"UWDC0000002","bot_id":null}
curl -s "$RYSTIC_URL/_rystic/calls?tail=1" | jq -c '.calls[] | {seq, principal, path, surface}'
# -> {"seq":4,"principal":"UWDC0000002","path":"/api/chat.postMessage","surface":"client"}
Binding tokens to users
A binding names a token and the user id it acts as. The bindings last until a reset, and each MCP server instance or agent gets its own token. The token alone names the caller only when it is bound; in a loaded scenario every other token acts as the scenario’s bot (UWDCBOT0001, studiobot, in design_studio). X-Rystic-Actor: <user id> acts as a seeded user instead, and wins over a binding.
What each identity sees
Each identity sees only what it belongs to. A private channel, DM or group DM is listed, read and posted to only by its members; anyone else gets channel_not_found. Public channels are listed to everyone with a per-caller is_member, a user token reads one without joining, and posting needs a join. Put anything an agent must not see in a private channel. A DM opened from either side is one conversation. Two bots cannot DM each other (cannot_dm_bot), as on Slack, so agents that DM should be regular users with xoxp- tokens.
Posting as a person
A post made with an app’s user token carries the app’s bot_id, app_id and bot_profile beside the poster’s user, as on Slack. To post as a person, add X-Rystic-Surface: client to a chat.postMessage made as a seeded non-bot user (a bound token or X-Rystic-Actor). The message is the one that user would type in the Slack client: user, team, text, blocks and a client_msg_id (yours when you pass one, else minted), with no bot_id, app_id or bot_profile.
A chat.scheduleMessage made the same way schedules that message, and it posts as the person when it falls due. The header names no one; the binding or actor still does, and a bot caller’s post is unchanged. Its only value is client; any other answers HTTP 400. Only chat.postMessage, chat.scheduleMessage and the client’s interaction methods read it.
The call log
GET /_rystic/calls lists every request since reset with its principal, for grading. Each entry carries args, the call’s arguments with tokens redacted, and ok: a refusal Slack answers at HTTP 200 has ok: false and its code in error, and a method the simulator does not have is also marked unknown_route: true.
?tail=N returns the last N calls. The log keeps the last 100,000 calls; post {"retain":0} to /_rystic/egress-retain to keep them all.