Documentation 30
Agent tutorial · Slack · 10 min
Test your agent against a frozen Slack.
An agent that reads Slack is only as testable as the data underneath it, and a real account gives you neither a fixed answer nor a safe place to be wrong. This page points a tool client at a pinned host whose bytes never change, runs one small agent task against it, and asserts the answer, so the run in CI tonight is the run you are doing now.
01 / Why a frozen replica
Three things an agent test needs.
- Determinism.
slack-2026-08-g12is a frozen snapshot, content addressed atc27f762cc1d2. Every byte it serves today is a byte it served yesterday, so an assertion on a reply count or a commit SHA is a test rather than a flake. - One coherent story. These rows are one simulated company,
olympus-labs, rendered into each vendor's dialect. The same incident is a thread on the Slack host,FATE-51on the Jira host and pull request 53 on the GitHub host, which is what lets an agent that crosses tools be judged on whether it crossed them correctly. - Nothing to babysit. No vendor sandbox to provision, no seed script, no token to rotate, nothing to clean up between runs. Every host is read-only, so the worst an over-eager tool call can do is produce the response in section 06.
02 / The host
One request, no key, no account.
The base URL is https://slack-2026-08-g12.snap.sandboxapis.dev. It serves Slack's own paths, JSON and headers, so the client you already use needs one line changed and nothing else. Send this before you write any code:
curl -s "https://slack-2026-08-g12.snap.sandboxapis.dev/api/conversations.history?channel=CF1UPUMGC8Q&limit=1" \
| python3 -c "import json,sys; m = json.load(sys.stdin)['messages'][0]; print(m['ts'], '|', m['reply_count'], 'replies |', m['text'])"1781726237.230977 | 11 replies | we have a live one: events dropped during a rebalance. filed the incident issue, details there.Anonymous callers get 60 req/hour, shared across every sandbox host rather than one bucket per host, which is enough to work through this page. A free key raises that to 600 req/hour and rides Slack's own auth slot, so an eval loop does not run out halfway. Keys and rate limits.
03 / The agent task
Is the incident thread resolved, who called it, and how many people were in it?
Save this as slack_agent.py and run it. It is plain requests, in the three beats an agent runs in: fetch the rows a tool would return, decide something from them, then check the decision. The middle beat is a pure function on purpose, so you can replace it with a model call and leave the assertions exactly as they are.
"""Agent task: is the incident thread resolved, who closed it, who was in it?
Every request goes to a frozen SandboxAPIs snapshot host, so this file is a
test: the answer is the same today, in CI tonight, and in a year.
"""
import requests
SLACK = "https://slack-2026-08-g12.snap.sandboxapis.dev" # the only line that changes
CHANNEL = "CF1UPUMGC8Q" # #incident-bridge
http = requests.Session()
http.headers["Authorization"] = "Bearer xoxb-any-token" # any token is accepted
def call(method, **params):
res = http.get("%s/api/%s" % (SLACK, method), params=params, timeout=30)
body = res.json()
if not body.get("ok"): # Slack reports failure in the body, not the status
raise RuntimeError("%s: %s" % (method, body.get("error")))
return body
# 1. FETCH. The same three calls your agent's Slack tools already make.
opener = call("conversations.history", channel=CHANNEL, limit=1)["messages"][0]
thread = call("conversations.replies", channel=CHANNEL, ts=opener["ts"], limit=999)["messages"]
names = {u["id"]: u["name"] for u in call("users.list", limit=200)["members"]}
# 2. REASON. The agent step: a pure function of the fetched data, so the same
# input always produces the same verdict. Swap in a model call here and the
# assertions below become an eval:
# answer = json.loads(client.messages.create(model="claude-sonnet-4-5",
# max_tokens=256, messages=[{"role": "user", "content": prompt}]) ...)
def triage(messages):
closing = [m for m in messages if "all clear" in m["text"].lower()]
return {
"resolved": len(closing) > 0,
"closed_by": names[closing[-1]["user"]] if closing else None,
"people": len({m["user"] for m in messages}),
}
answer = triage(thread)
print("%d messages in #incident-bridge -> %s" % (len(thread), answer))
# 3. ASSERT. Known facts of the olympus-labs data set, frozen in this snapshot.
assert names[opener["user"]] == "cassandra", names[opener["user"]]
assert opener["reply_count"] == 11, opener["reply_count"]
assert len(thread) == opener["reply_count"] + 1, len(thread)
# The parent leads and the burst runs forward: nobody replies before the thread
# was opened.
assert thread == sorted(thread, key=lambda m: float(m["ts"]))
assert answer == {"resolved": True, "closed_by": "tiresias", "people": 6}, answer
print("ok")
12 messages in #incident-bridge -> {'resolved': True, 'closed_by': 'tiresias', 'people': 6}
okThe commented line inside the reasoning step is where a real model call goes. Everything above it is tool output and everything below it is judgement, which is the split that makes an eval readable: when the assertion fails you know whether the tools returned the wrong rows or the model read the right ones wrongly.
04 / In CI
Five lines that drop into a suite.
The same claim, as a test the runner you already have will collect. No fixtures to check in, no recorded cassettes to refresh, no credential in the job's environment:
import requests
PIN = "https://slack-2026-08-g12.snap.sandboxapis.dev/api"
def test_the_incident_thread_is_a_real_burst():
body = requests.get(PIN + "/conversations.history", params={"channel": "CF1UPUMGC8Q", "limit": 1}, timeout=30).json()
assert body["ok"] and body["messages"][0]["reply_count"] == 11
Because the host is pinned rather than live, this test has no expiry date. The live host at https://slack.sandboxapis.dev re-rolls its data daily, which is what you want for a demo and the one thing you must not point a test at. Versioning and pinning covers the registry and how to choose a pin.
05 / MCP instead of HTTP
Same universe, through tools.
If your agent prefers tools to raw HTTP, @sandboxapis/mcp serves the same rows through orient, the read tools and check_budget. One environment variable points it at the pin instead of the live host, which makes the tool results as reproducible as the request above. Drop this into Claude Desktop's claude_desktop_config.json or Cursor's mcp.json:
{
"mcpServers": {
"sandboxapis": {
"command": "npx",
"args": ["-y", "@sandboxapis/mcp"],
"env": {
"SANDBOXAPIS_BASE_URL_SLACK": "https://slack-2026-08-g12.snap.sandboxapis.dev"
}
}
}
}The variable is SANDBOXAPIS_BASE_URL_SLACK, and every provider has one, so an agent can be pinned on the surfaces a test cares about and left live everywhere else. MCP setup has the full tool list and the one-line install for Claude Code.
06 / When the agent writes
The refusal is legible, not a crash.
An agent under test will eventually try to change something. Here is what that costs, captured from this host rather than described:
curl -s -i -X POST https://slack-2026-08-g12.snap.sandboxapis.dev/api/chat.postMessage \
-H "Content-Type: application/json" \
-d '{"channel":"CF1UPUMGC8Q","text":"posted by an agent under test"}'{"ok":false,"error":"read_only_universe","req_method":"chat.postMessage"}It is Slack's own error envelope, so your client raises the exception it would normally raise, and the message names the exact request that was refused. The status is 200 because this vendor reports failure in the body rather than in the status line, and an exact mirror has to do the same. The verdict is ok: false, and the headers carry the sentence the body has no field for:
x-sandboxapis-read-only: true
x-sandboxapis-reason: This universe is read-only. See what's covered at https://sandboxapis.dev/roadmap. (You tried to call chat.postMessage.) Tell us what you needed to write: https://sandboxapis.dev/feedbackNothing is mutated, and the next run reads exactly the same rows.
Next
Where to go from here.
The other two tutorials read the same incident from the other side: Frozen GitHub and Frozen Jira.
All agent tutorials · SandboxAPIs for agents · Verified quickstarts · Slack quickstart · Versioning and pinning · Coverage manifest