The Slack template (apps/slack-clone, name slack) is the reference clone: a
NestJS backend on Postgres, an optional React frontend, and an MCP server. Its
data plane is the real Slack Web API — RPC method names (chat.postMessage),
args in the request body, the { "ok": true, … } envelope, Slack-format object
ids (T…/U…/C…/D…/G…/A…), per-method rate-limit tiers, and byte-identical Events
API webhooks. Auth works two ways: a JWT for the web UI, and Slack-style opaque
bearer tokens (xoxb-… / xoxp-…) for programmatic access. It’s faithful
enough at the wire-format level that a client built for @slack/web-api can
drive it.
Spinning a clone auto-provisions a default app and prints a bot token and a
user token you can use immediately — see Apps and tokens.
What’s inside
| Piece | Notes |
|---|
backend/ | NestJS Slack Web API (RPC) + Socket.io realtime, Postgres via pg. Global prefix /api, health at /api/health, Swagger at /api/docs. |
frontend/ | React + Vite UI. Runs only in full mode. |
mcp-server/ | MCP server exposing the eight reference Slack MCP tools (+ labeled extensions). |
supabase/migrations/ | 001_initial_schema.sql + 002_apps_and_tokens.sql — the schema (incl. gen_slack_id()), run on spin and replayed on reset. |
seeds/ | acme-corp.sql — the bundled deterministic fixture. |
Modes
| Mode | Services | Use it for |
|---|
api (default) | backend | Agents that talk to the Web API or MCP server. |
full | backend + frontend | When you also want the UI. |
asymmetric spin slack --mode full
Seeding
asymmetric spin slack --seed acme-corp # deterministic fixture on create
asymmetric seed slack-a1b2 --ai # realistic data via the Web API
AI seeding generates users, channels, and messages (the template’s declared
entities) and creates them through real signup / conversations.create /
chat.postMessage calls. See Seeding.
Apps and tokens
Real Slack apps authenticate with bearer tokens (xoxb- for bots, xoxp- for
users) and are configured by an app manifest. The clone mirrors that: it has a
first-class app entity with a stored manifest, opaque bearer tokens, and
manifest scopes that are enforced on every app-token call.
Tokens you get for free
Every spin slack auto-provisions a default workspace, an admin user, and a
default app, then prints a bot token and a user token:
✓ 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-…
Use either as a bearer token against the Web API — no signup/login step. The
identity check is auth.test (the real Slack method), POSTed with the token:
curl -s -X POST http://127.0.0.1:3001/api/auth.test \
-H "authorization: Bearer xoxb-…"
# → { "ok": true, "user_id": "U…", "team_id": "T…", "user": "…", … }
A bot token acts as the app’s bot user (messages it posts are attributed to
the bot, is_bot: true); a user token acts as the admin user. This is the
fastest way to point an agent at a clone — see
Connect your agent.
Get the tokens again later
Tokens are stored locally so you can reprint them any time:
asymmetric tokens slack-a1b2 # print the bot + user tokens
asymmetric tokens slack-a1b2 --json # machine-readable
asymmetric tokens slack-a1b2 --reprovision # rotate (old tokens stop working)
See the tokens reference.
Create your own app
Post a Slack-shaped manifest to create additional apps (each gets its own bot
user and bot token). App management is a clone-operation, so it uses a plain REST
shape, not the { ok } envelope:
curl -s -X POST http://127.0.0.1:3001/api/apps \
-H "authorization: Bearer xoxp-…" \
-H 'content-type: application/json' \
-d '{"manifest":{"display_information":{"name":"My App"},"oauth_config":{"scopes":{"bot":["chat:write","channels:read"]}}}}'
App-token scopes are enforced. Each Web API method declares the Slack scope
it needs (chat:write, reactions:write, channels:history, search:read, …);
a token missing it gets 403 { "ok": false, "error": "missing_scope" }. JWT /
web-UI humans are exempt. OAuth install and auth.revoke are not implemented yet.
Clones bind to localhost by default, so a token is not reachable off your machine
unless you --expose.
API shape — read this before pointing an agent at it
The data plane is Slack’s RPC Web API, so it matches at the wire-format level,
not just the capability level:
- RPC method routes —
POST /api/chat.postMessage, conversations.*,
reactions.*, users.*, search.messages, auth.test — args in the JSON body
(channel, ts, timestamp, users, name, …), exactly how @slack/web-api
calls them.
- The
{ ok } envelope — success is { "ok": true, … }; failures are
HTTP 200 { "ok": false, "error": "<code>" }, with cursor pagination under
response_metadata.next_cursor.
- Slack-format ids — every object id is minted in Slack’s grammar at the DB
layer (
T… team, U… user, C… channel, D… IM, G… mpim, A… app). No
UUIDs leak into payloads.
- Rate-limit tiers — each method enforces its Slack tier; a breach returns
HTTP 429 + Retry-After + { "ok": false, "error": "ratelimited" } (see
below).
- Byte-identical events — outgoing webhooks deliver the full Slack Events API
event_callback envelope with inner objects matching Slack field-for-field
(message, reaction_added/removed, channel_created, channel_archive,
member_joined/left_channel, user_change, presence_change).
A quick tour against a running clone:
B=http://127.0.0.1:3001/api
A="authorization: Bearer xoxb-…"
# Post a message (channel id is a Slack-format C…)
curl -s -X POST $B/chat.postMessage -H "$A" -H 'content-type: application/json' \
-d '{"channel":"C…","text":"hello from an agent"}'
# List conversations, then read history
curl -s -X POST $B/conversations.list -H "$A" -d '{"types":"public_channel"}'
curl -s -X POST $B/conversations.history -H "$A" -d '{"channel":"C…","limit":20}'
# React to a message by channel + ts (Slack's keying, not a UUID)
curl -s -X POST $B/reactions.add -H "$A" \
-d '{"channel":"C…","timestamp":"1718900000.000100","name":"thumbsup"}'
# Full-text search across visible channels
curl -s -X POST $B/search.messages -H "$A" -d '{"query":"hello"}'
Implemented method surface
The data plane implements 27 Slack methods across messaging, conversations,
reactions, users, search, and auth:
| Family | Methods |
|---|
chat | postMessage, update, delete |
conversations | list, create, info, history, replies, members, join, leave, archive, invite, kick, rename, setTopic, setPurpose, open |
reactions | add, remove |
users | list, info, profile.get, profile.set, setPresence |
search | messages |
auth | test |
Whole Slack families outside this core (files.*, pins.*, bookmarks.*,
dnd.*, views.*, usergroups.*, admin.*, …) are unimplemented by design —
this is an MVP+Beta vertical slice, not the full ~130-method Web API.
Rate-limit tiers
Each method is bound to its real Slack tier and returns 429 + Retry-After +
{ "ok": false, "error": "ratelimited" } on breach:
| Tier | Allowance | Example methods |
|---|
| Tier 1 | ~1 / min | (heaviest admin-style methods) |
| Tier 2 | ~20 / min | reactions.remove, users.setPresence |
| Tier 3 | ~50 / min | conversations.members |
| Tier 4 | ~100 / min | most reads; auth.test (a high allowance) |
| Special | ~1 / s / channel | chat.postMessage |
Control plane (clone-operations — intentionally REST)
Operations that real Slack configures via app manifests / OAuth rather than
runtime Web API methods keep plain REST shapes and are not wrapped in the
{ ok } envelope:
Auth POST /api/auth/{signup,login,refresh,logout} GET /api/auth/me
Workspaces GET/POST /api/workspaces POST /api/workspaces/:id/join GET/PUT/DELETE /api/workspaces/:id
Webhooks POST /api/webhooks/incoming/:id GET/POST /api/webhooks/{incoming,outgoing} DELETE …/:id
Events POST /api/events/subscribe GET /api/events/subscriptions DELETE /api/events/subscriptions/:id
Commands GET/POST /api/commands DELETE /api/commands/:id POST /api/commands/execute
Interactions POST /api/interactions Apps POST/GET /api/apps Admin POST /api/admin/provision
MCP server
The mcp-server exposes the eight reference Slack MCP tools with their exact
names and argument names — slack_list_channels, slack_post_message,
slack_reply_to_thread, slack_add_reaction, slack_get_channel_history,
slack_get_thread_replies, slack_get_users, slack_get_user_profile — with
tool descriptions restored character-for-character to the reference server
(modelcontextprotocol/servers-archived),
plus a few clearly-labeled slack_* extensions (slack_auth_test,
slack_search_messages, slack_open_dm). It binds to one SLACK_MCP_TOKEN
(xoxb/xoxp), resolves it to identity + workspace + scopes, and acts strictly
as that identity — gating each tool on its scope and scoping every query to the
token’s workspace.
The template ships an API_PARITY.md scored by the strict /verify-api slack
workflow, which fetches Slack’s live docs every run and treats identifier,
envelope, id-format, rate-limit tier, event-schema, and MCP-verbatim conformance
as blocking checks. Check apps/slack-clone/API_PARITY.md for the per-method
breakdown.
The verify command will fold this kind of live fidelity
scoring into the CLI. Until then, API_PARITY.md and the /verify-api slack
workflow are how fidelity is tracked.
Inspect a running clone
asymmetric query slack-a1b2 "select id, name from channels"
asymmetric db slack-a1b2 # prints a psql shell command
asymmetric logs slack-a1b2 -f # stream the backend
The schema (channels, messages, users, workspaces, apps, …) comes straight from
001_initial_schema.sql — read it to know what’s queryable.