@volter/twin-slack
v2.0.0
Published
Local Slack twin — Web API + Events API; your real `@slack/web-api` talks to it unmodified. Mirror, simulate, and fork. Built on @volter/world-core.
Readme
@volter/twin-slack
The Slack twin: a local replica of Slack's method-style Web API (POST /api/<method> → { ok, … }),
derived from Slack's published spec (248 methods, 227 served by the pack's semantics). It covers a workspace's
life across Slack's plans: messaging, files, reactions, pins, saved items, reminders, do-not-disturb, user groups,
calls, canvases, views and workflows; apps (manifests, OAuth installs, app-level tokens and Socket Mode, the App
Directory's install requests); and, on Enterprise Grid, the Admin API and SCIM that an org admin or an identity
provider calls. What only Slack's pages do (an app's settings, "Invite people" and install requests, the OAuth
consent) the pack serves as screens. Web API refusals are Slack's HTTP 200 { ok: false, error }; incoming
webhooks answer instead with HTTP statuses and plain-text bodies (ok, invalid_payload, channel_is_archived, …).
The real @slack/web-api WebClient works against it unmodified via slackApiUrl.
Surface
- Web API (
slack-twin.ts; HTTP wrapperslack-server.ts→createSlackTwinServer): conversations.list/info/history, users.list/info, auth.test. - Writes (
applySlackWrite): chat.postMessage, chat.update, conversations.create — entries in the shared kernel tree; the --read-only flag returnsok:false. - OAuth (
screens/oauth.tsx,semantics/apps.ts): an install or a Sign in with Slack names an app made withapps.manifest.create(a configuration token from api.slack.com/apps), and the consent shows the app'sdisplay_information.name. A client_id with no app is refused on Slack's error page ("Something went wrong when authorizing this app", "Error details: Invalid client_id parameter"), and oauth.v2.access / openid.connect.token answerinvalid_client_id, orbad_client_secretfor a secret not the app's. Register the app first, as an operator does. The World's own app is the exception: when the World's env setsSLACK_CLIENT_ID(withSLACK_CLIENT_SECRETandSLACK_SIGNING_SECRET;initmints them), the twin takes it as an app already made, read only throughworldEnvValue, never from the caller's shell. A client id with no secret is no app. Its name is the World's name (VOLTER_WORLD_NAME, the instance name, world.json'sidby default); it declares no Redirect URLs, so it goes back to the one each request names (a request naming none is refused), and it declares no slash commands or Request URLs; consent, oauth.v2.access, openid.connect.token and apps.uninstall take its client id and secret. An app made with the same client id takes precedence. The workspace client's deliveries to a made app's Request URL (/_twin/client/*) carryX-Slack-Request-TimestampandX-Slack-Signaturesigned with its signing secret; the timestamp is the World clock's, so a verifier that also rejects future timestamps refuses deliveries from a clock set away from now. An app another company made in its own workspace and publicly distributed is stated throughPOST /_twin/apps {workspace, manifest}(the World holds only its customer's workspace), which answers apps.manifest.create's credentials. - Incoming webhooks (
slack-webhooks.ts): an install asking forincoming-webhookhas the installer pick a channel on the consent (a public one, or a private one they are in; with none, the consent offers only Cancel), and oauth.v2.access answersincoming_webhook{ channel, channel_id, configuration_url, url }. A POST to thehooks.slack.com/services/…url posts as the install's bot into that channel:200 ok, or400 no_text/400 invalid_payload,404 no_service,404 channel_not_found,410 channel_is_archived. A picked channel that is gone refuses the exchange instead of answering without the webhook, and an org-level install asking forincoming-webhookis refused at the consent. - Events (
slack-events.ts): Events-APIevent_callbackenvelopes on write (message,channel_created). - Conformance (
slack-conformance.ts): field-name subset vs the vendored Slack channel/user/message object schemas (standing gate; also strips connector bookkeeping likecreatedAtso served objects are pure Slack shapes). - UI mirror (
slack-mirror-ui.ts): a Slack-style workspace (React), a pure frontend (R3) — the shell + assets with the twin's own fetch adapter mounted beside them. It reads the workspace through the twin's store doorGET /twin/store/mirror(the one named projectionslack-mirror-state.tsbuilds server-side, through the twin's own served reads) and writes through the Web API (POST /api/chat.postMessage).--wirehides the doors, so the client renders from Web API reads alone, as it must against a real workspace. Pure helpers both sides share live inslack-shared.ts.
CLI
world-slack serve [--read-only] [--port N] [--root DIR]
world-slack mirror [--wire] [--port N] [--host ADDR] [--root DIR]
world-slack conformance [--root DIR]
world-slack shadow --channel C…[,C…] [--loop SECONDS] [--limit N] [--root DIR]shadow mirrors real channels into the local twin pull-only (needs SLACK_TOKEN/
SLACK_USER_TOKEN/SLACK_BOT_TOKEN): incremental via durable per-channel cursors under
--root, file bytes included, one JSON report per tick. Each tick drains every
cursor-paginated history page newer than the durable high-water mark before advancing it;
provider errors and failed file downloads abort the tick without acknowledging those
messages, so a later tick can retry them instead of silently losing data. It imports no
push path — local writes stage as pending actions and are counted in the report, never
applied — and no SDK: the pack's own makeSlackReadClient(token) covers the pull surface.
Programmatic form: slackShadowTick(client, { channels, root, occurredAt }).
Point the real @slack/web-api WebClient at it via slackApiUrl
(http://127.0.0.1:<port>/api/).
SLACKAPP_TWIN_URL optionally routes slack.com/api/apps.connections.open through a
separate app-token credential boundary. It takes precedence for that method; all other
Web API calls use SLACK_TWIN_URL. With only SLACK_TWIN_URL, routing is unchanged.
World initialization points both variables at the same Slack service. The standalone API and mirror servers support Socket Mode for the synthetic app A-twin:
use an xapp-* app token with the real Slack Socket Mode client. apps.connections.open
returns a one-use, one-minute URL on the serving origin (ws locally, wss over HTTPS).
Connections receive hello and events_api envelopes from successful Web API writes;
thread, bot, mention, edit and deletion fields come from the same stored messages.
Acknowledge envelope_id; unacknowledged envelopes have up to two retries at three-second
intervals. Up to ten connections share delivery; each event goes to one connection, not all.
Transport state is per server, cleared on stop; offline/restart replay is not supplied.
A fetch-only host must mount SlackSocketMode.upgrade through its WebSocket server and pass
that same instance as socketMode to createSlackTwinFetch. Without a transport,
apps.connections.open returns not_supported, never a dead URL. Socket Mode interactive
payloads, configurable app subscriptions, and RTM typing/presence remain open coverage.
Interaction surfaces
- SDK/API — zero edits (preferred):
SLACK_TWIN_URL=http://127.0.0.1:PORT node --require @volter/world-core/inject your-appredirects the real@slack/web-apifromslack.comto the twin. Or override directly:new WebClient(token, { slackApiUrl: 'http://127.0.0.1:PORT/api/' }). - API + CLI — run the twin in a World and inspect it with
volter world loganddiff. Use the deployment guide for changeset review and deployment. - Read-only —
world-slack serve --read-only: unlimited local reads, no rate limits; writes refuse like Slack ({ ok: false }). - UI mirror —
world-slack mirrorrenders a Slack-style workspace over the twin's state.
See getting started and zero-edit injection.
Changesets and deployment
The deployment guide owns the shared changeset, verify, approve, push and deploy workflow. Push moves entries between Worlds; the root's policy controls vendor execution. Consult this package's declared capabilities and protocol standing for supported Slack operations; do not use removed plan/review helpers.
Coverage
Goal: honest, explicitly tracked coverage of Slack's core feature surface. Every capability is either done or a tracked todo; anything not done is a gap to close.
Done — conversations: list/info/history/replies/members + create/join/leave/invite
(membership tracked, num_members/is_member updated); chat: postMessage/update/delete/
getPermalink + scheduleMessage/deleteScheduledMessage/scheduledMessages.list; threads
(parent/reply split, reply_count/reply_users/latest_reply derived); reactions add/
remove/get; pins add/list (pin_count, pinned_to); bookmarks add/list; users
list/info + profile.get/set (incl. custom status), getPresence + setPresence, dnd.info +
setSnooze/endSnooze; files info/list/delete (+ derived shares/channels); team.info;
views open/update/push/publish (modals + App Home, hash optimistic-concurrency); Canvas
create/edit/delete + sections.lookup + conversations.canvases.create (markdown body parsed into
addressable sections; channel canvas stamps the real properties.canvas channel field; canvas
state held in twin-internal canvas subjects, never in slackState); Block Kit
blocks + message metadata round-trip (stored verbatim, returned on history); opaque
cursor pagination + oldest/latest/inclusive time windows; faithful { ok, ... }
shapes with real Slack error codes; Events-API emission (event_callback on write:
message/channel_created/reaction/pin/member/file); interactivity dispatch — delivers
vendor-faithful block_actions / view_submission / view_closed payloads (deterministic
trigger_id from occurredAt+seq; modal payloads embed the REAL stored view + merged
state.values) and slash-command POSTs (command/text/user_id/channel_id/
response_url/trigger_id) to registered endpoints via injected delivery (fake in tests,
HTTP POST live — no real network), incl. the ack/response_action (clear/update/push/errors)
shape; R2 conformance + recorded-diff gates;
UI mirror (rung-5 structural DOM checklist ✅, incl. a huddle tray for the Calls API); connector pull (channel + history,
incremental cursor) + push (postMessage/update/delete/scheduleMessage/deleteScheduledMessage,
reactions, conversations create/join/leave/invite, pins.add, files.delete, bookmarks.add,
profile.set, setPresence, dnd snooze/end — confirmed per action);
Calls API (calls.add/update/end/info + participants.add/remove — used to model huddles
as in-channel live calls, rendered in the huddle tray); Workflow Builder steps
(workflows.stepCompleted/Failed/updateStep) + custom functions (functions.completeSuccess/Error)
- workflow triggers (workflows.triggers.create/list/update/delete — link/shortcut/event/
scheduled/webhook records, with minted webhook/shortcut urls);
legacy secondary message attachments (chat.postMessage/update — normalized 1-based
id, round-trip on history); file comments (files.comments.add/list/delete — bumpscomments_count); files.remote.* (add/info/update/remove/list — external file refs asmode:externalfile objects, addressable byfileid orexternal_id); file BYTES (files.upload'scontentparam and the full v2 flow — getUploadURLExternal → POST bytes toupload_url→ completeUploadExternal — store real content content-addressed under this service's own world-state dir (slack-blobs.ts— a PACK-LOCAL key space over the kernel'sBlobStoreseam (runtime contract R11), so a local world'sworld scrubdeletes it like every other pulled chat artifact while a hosted namespace serves the same keys out of object storage);url_private/url_private_downloadserve the exact bytes back with bearer auth, API responses rewrite twin-domain URLs to the live server origin, the connector push replays uploads via files.uploadV2 with the stored bytes (as the SAMEfile.uploadaction — no separate pending companion), and pull takes an injectedfetchFileBytesdownloader, folded through the shared observation path used for mutable resources in this pack, to mirror inbound file content without orphaning bytes that arrive after metadata — failed content downloads refuse cursor advancement so the entire message is retryable;makeUrlPrivateFetcher(token)is the exported default, and shadow mode (world-slack shadow/slackShadowTick) wraps sync into a pull-only mirror loop with durable cursors that advance only after all newer history pages are drained); world-clock stamping on write methods (every write is stamped with the world's clock — deterministic history is clock-set → seed → clock-advance); Enterprise Grid admin.* — admin.users (invite/remove/role changes + list), admin.conversations (archive/unarchive/delete/rename/convertToPrivate/setTeams + search + getConversationPrefs), admin.teams (create/settings + list), admin.apps (approve/restrict + approved/restricted lists); SCIM provisioning (Users create/list/delete, Groups create/list — mapped to chat users/subteams); Audit Logs API (audit.v1.logs/actions/schemas — admin.* role/channel/app/workspace actions append faithful audit entries, read back newest-first + action-filtered); Slack Connect (conversations.inviteShared + externalInvitePermissions.set — stamps the realis_pending_ext_shared/pending_sharedchannel fields); canvas sharing (canvases.access.set/delete); Sign in with Slack (users.identity); team.accessLogs/billableInfo/integrationLogs; app lifecycle (apps.connections.open negotiation url + apps.uninstall).
Planned (known-missing, will do) — scheduled DND windows (dnd.setSnooze models the
manual snooze only, so next_dnd_start_ts/next_dnd_end_ts are not real schedule bounds);
search.messages deepening (substring matching over stored text, not Slack's query
grammar); no live dispatch of Block Kit view submissions (views are stored for
read-back, not driven by an interaction loop).
Planned (todo) —
- Socket Mode / RTM websockets: serve the persistent transport and push events over it (the HTTP Web API + Events API surface is fully modeled).
- admin.analytics.getFile bytes and the unsupportedVersions export zip: produce the dump and the report from the workspace/session state the twin holds (the envelopes are modeled).
- Legacy binary multipart on files.upload: the deprecated
file=multipart field is not parsed (Slack itself deprecated this path in favor of the v2 flow, which the twin models WITH bytes). Uploads that arrive with neithercontentnor the v2 flow keep a real-shaped file object with declared size and no stored content — and the connector still skips replaying those loudly rather than pushing a hollow file. - Ephemeral typing indicators ride along with the RTM transport above; avatar/team-icon URLs are synthetic rather than stored image bytes.
Rate budget — the fail-closed backstop on live calls
makeSlackReadClient is the one place this pack issues a live request, so every call it makes is charged
against a persistent, fail-closed spend ledger before the request goes out. Past the ceiling, or
while a Retry-After/429 cooldown is armed, it throws instead of calling. The ledger is keyed by
vendor and a hash of the credential (limits are per credential, so it is deliberately not
cwd-scoped) and persists across processes, so a fresh process does not get a fresh allowance; a
corrupt ledger counts as a full window rather than zero spend. There is no option to disable it,
and no value you can pass for budget that yields an unguarded client — an injected budget is
validated by method identity, so a subclass or a Proxy that replaces checkBudget is refused.
The declared numbers: 60 weighted units / 60s at weight 2 = 30 calls/minute — exactly the kernel's austere fallback, because Slack's tightest published tier is 1+ per minute. conversations.history/.replies cost 12 (5 a minute, a tenth of their nominal Tier 3). ⚠️ Slack cut those two methods to 1 request/minute for non-Marketplace apps created after 2025-05-29; this ceiling does not model that — it is sized for the Tier 3 a Marketplace-approved or internal app still gets, and for such an app the vendor's 429 arrives first and is converted into a persisted stop. ⚠️ This guards the client the pack builds; the push/sync entrypoints take an injected SlackClientLike whose calls do not pass through it.
The mechanism is shared and vendor-agnostic — it lives in the kernel (@volter/world-core →
packages/world-core/src/rateBudget.ts); what lives here in src/slack-budget.ts is this vendor's
declaration (window, ceiling, per-endpoint weights, and a reason citing the limits above) plus
the vendor-bound SlackBudget. The rule is ratified as
../../../docs/contributing/architecture.md D8, and the kernel module's header documents what the
guard does not guarantee — read that before trusting it.
