Skip to main content
A clone is only useful once your agent is acting against it. There are two surfaces to connect to, and you can use either or both:
SurfaceWhat it isUse it when
HTTP APIThe clone’s HTTP API at the endpoint spin printed. For Slack this is the real Slack Web API — RPC methods, the {ok} envelope, opaque bearer tokens.Your agent already speaks the product’s API, or you want full control. The default, fully-wired path.
MCP serverA Model Context Protocol server that exposes the clone as a set of tools.Your agent is an MCP client (Claude Desktop, an SDK agent) and you want tool-shaped access.
This guide assumes you have a running, seeded clone from the Quickstart. Everything below uses the Slack template, slack-a1b2, and port 3001 — substitute your own clone id and endpoint.

Option A — the Slack Web API

spin hands you a base URL and two ready-to-use tokens:
  ✓ slack-a1b2  (api)  http://127.0.0.1:3001/api  ready in 38.2s
    db clone_slack_a1b2 · env -
    bot token   xoxb-…
    user token  xoxp-…
That base URL is your agent’s target, and it’s the real Slack Web API: RPC method routes (POST /api/chat.postMessage), args in the JSON body, and the { "ok": true, … } envelope. The clone expects real auth — you have two ways in.

Fastest — the provisioned bot token

Every spin slack auto-provisions a default workspace, admin user, and app, then prints a bot token (xoxb-…) and a user token (xoxp-…). No signup or login; send one as a bearer token. Reprint them any time with asym tokens <id>.
B=http://127.0.0.1:3001/api
TOKEN=$(asym tokens slack-a1b2 --json | jq -r .botToken)

# Confirm identity with auth.test (a real Slack method)
curl -s -X POST $B/auth.test -H "authorization: Bearer $TOKEN"
# → { "ok": true, "user_id": "5f3a8b2c-…", "team_id": "9d1e7a4f-…", "user": "admin" }
# (ids are internal UUIDs — a known cosmetic deviation from Slack's `U…`/`T…`)

# List conversations
curl -s -X POST $B/conversations.list -H "authorization: Bearer $TOKEN" \
  -H 'content-type: application/json' -d '{"types":"public_channel"}'
A bot token acts as the app’s bot user (messages it posts show is_bot: true); a user token acts as the admin. App-token scopes are enforced — a method whose scope the token lacks returns { "ok": false, "error": "missing_scope" } at HTTP 200, like every Slack-method error (the clone never returns 4xx/5xx on a method route — the one non-200 is 429 ratelimited). See Apps and tokens.

Per-user — JWT login

When you want to act as a specific seeded human (e.g. to test multi-user flows), log in for a JWT instead. JWT/web-UI identities are exempt from scope enforcement, and the access token is used exactly like the bearer token above.
1

Authenticate

Log in as a seeded user (or sign up your own) to get an access token. The acme-corp fixture creates six users who all share the password password123dana@acme.test is one:
TOKEN=$(curl -s -X POST http://127.0.0.1:3001/api/auth/login \
  -H 'content-type: application/json' \
  -d '{"email":"dana@acme.test","password":"password123"}' \
  | jq -r .accessToken)
(/api/auth/* is a clone-operation, so it keeps a plain REST shape rather than the { ok } envelope.)
2

Act

Now your agent drives the Web API like any client — list channels, then post a message. Take a channel id from conversations.list (the clone uses internal UUID ids — a known cosmetic deviation from Slack’s C… that doesn’t change call signatures), and chat.postMessage takes its args in the body:
B=http://127.0.0.1:3001/api
curl -s -X POST $B/conversations.list -H "authorization: Bearer $TOKEN" \
  -H 'content-type: application/json' -d '{"types":"public_channel"}'

curl -s -X POST $B/chat.postMessage -H "authorization: Bearer $TOKEN" \
  -H 'content-type: application/json' \
  -d '{"channel":"<channel-id from conversations.list>","text":"hello from my agent"}'
3

Point your agent at the base URL

Hand http://127.0.0.1:3001/api to your agent as its API base, with the token in its auth header. Every action it takes lands in the clone’s own database — which is exactly what you’ll read back to score the run.
The Slack clone matches Slack at the wire-format level, not just the capability level — RPC method names (chat.postMessage), the { ok } envelope, error-as-HTTP-200 semantics, and per-method rate-limit tiers. (Object ids are the one known deviation — internal UUIDs rather than C…/U… — which doesn’t change call signatures.) A client built for @slack/web-api can drive it. See the Slack template for the full method surface and parity breakdown.

Option B — the MCP server

The Slack template ships an MCP server (apps/slack-clone/mcp-server) that exposes the clone as the reference Slack MCP tools — handy for MCP-native agents that prefer tools over raw HTTP. It speaks stdio, connects to the clone’s Postgres database, and is bound to a single app token so every tool acts strictly as that identity, inside that workspace, limited to that token’s scopes. The tools it exposes (verbatim names from the reference Slack MCP server):
ToolDoes
slack_list_channelsList channels in the workspace.
slack_post_messagePost a message to a channel.
slack_reply_to_threadReply in a thread.
slack_add_reactionReact to a message.
slack_get_channel_historyRead a channel’s recent messages.
slack_get_thread_repliesRead a thread’s replies.
slack_get_usersList workspace users.
slack_get_user_profileLook up one user’s profile.
Plus a few clearly-labeled extensions: slack_auth_test, slack_search_messages, slack_open_dm.

Wire it up

The MCP server needs two things: a DATABASE_URL pointing at the clone’s database, and a SLACK_MCP_TOKEN — the bot (xoxb-…) or user (xoxp-…) token from spin that fixes its identity. The clone’s database is named after its id (slack-a1b2clone_slack_a1b2) inside the shared Postgres.
Shared Postgres deliberately publishes no host port — clones reach it over the asym-shared Docker network. So a process on your host can’t connect to localhost:5432. Run the MCP server on the asym-shared network (as a container, or via docker compose), where the database is reachable as host postgres. The MCP server is not auto-provisioned by spin today — you run it yourself.
Build the server once, then run it with both env vars set on the shared network:
# Build the MCP server
cd apps/slack-clone/mcp-server && bun install && bun run build

# DATABASE_URL reaches the clone's db as host `postgres` on asym-shared;
# SLACK_MCP_TOKEN is a bot/user token from `asym tokens slack-a1b2`.
DATABASE_URL=postgresql://postgres:postgres@postgres:5432/clone_slack_a1b2 \
SLACK_MCP_TOKEN=xoxb-… \
  node dist/index.js

Register it with an MCP client

Point your MCP client at the built server over stdio. For a Claude Desktop-style config:
{
  "mcpServers": {
    "slack-clone": {
      "command": "node",
      "args": ["/abs/path/to/apps/slack-clone/mcp-server/dist/index.js"],
      "env": {
        "DATABASE_URL": "postgresql://postgres:postgres@postgres:5432/clone_slack_a1b2",
        "SLACK_MCP_TOKEN": "xoxb-…"
      }
    }
  }
}
Your agent now sees slack_post_message, slack_list_channels, and the rest as tools — each operating on the clone’s real data, gated by the bound token’s scopes.

Score the run

However your agent connected, its work is now rows in the clone’s database. Read them back to grade the run — see Inspect what happened and the query reference:
asym query slack-a1b2 "select count(*) from messages"
asym query slack-a1b2 "select user_id, text from messages order by created_at" --json
When the trial is done, asym reset slack-a1b2 returns the clone to its seeded starting state for the next run.

Where to go next

Seeding

Give your agent a known starting state — fixtures vs AI data.

Slack template

The clone’s API shape, schema, and fidelity to real Slack.

query reference

Read the clone’s database to score what your agent did.

Lifecycle commands

Reset, stop, start, and destroy between trials.