Founding keys are opena year free at 6,000 req/hour, for the first 1,000 developersClaim yours
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.

01Point at the pinned host
02Run the agent task
03Assert the answer in CI

01 / Why a frozen replica

Three things an agent test needs.

  • Determinism. slack-2026-08-g12 is a frozen snapshot, content addressed at c27f762cc1d2. 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-51 on 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:

terminal
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'])"
output
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.

slack_agent.py
"""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")
output
12 messages in #incident-bridge -> {'resolved': True, 'closed_by': 'tiresias', 'people': 6}
ok

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

test_slack_agent.py
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:

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:

terminal
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"}'
json · HTTP 200
{"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:

headers
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/feedback

Nothing 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