@volter/twin-intercom
v0.1.35
Published
Local Intercom twin — a faithful, stateful local Intercom REST API your unmodified intercom-client talks to. Mirror, simulate, and fork. Built on @volter/world-core.
Readme
@volter/twin-intercom
The Intercom twin — a local, stateful, vendor-faithful replica of the
Intercom REST API
(api.intercom.io, Intercom-Version: 2.14) on the shared @volter/world-core
kernel. An unmodified intercom-client points at it and gets vendor-correct responses.
State lives in the kernel's append-only action/event log — a write appends an action that projects into visible state; reads use the kernel’s tree. No real Intercom is ever called (the only network path is the connector, over an injected client). Auth is a faked-locally Bearer access token — accepted, never verified. There is no OAuth token minting and no webhook HMAC verification here: both are the app-credential surface, not the REST data surface (both are filed as todos).
Usage
bun run src/cli.ts serve # the Intercom REST API twin (writable)
bun run src/cli.ts serve --read-only
bun run src/cli.ts mirror # the React inbox mirror (conversations + contacts)
bun run src/cli.ts conformance # offline spec check over projected statePoint the real SDK at it with the SDK's own public option — configuration, not modification:
import { IntercomClient } from 'intercom-client';
const client = new IntercomClient({ token: 'anything', baseUrl: 'http://127.0.0.1:<port>' });
await client.contacts.create({ email: '[email protected]', role: 'user' });Responses use Intercom's real envelopes: lists are
{ "type": "list", "data": [...], "total_count": N, "pages": { "type": "pages", "page", "next", "per_page", "total_pages" } }
(conversations use { "type": "conversation.list", "conversations": [...] }), and every error is
{ "type": "error.list", "request_id": "…", "errors": [{ "code", "message", "field"? }] }.
Timestamps are unix seconds, never ISO strings. Pagination is Intercom's opaque
starting_after cursor (per_page + starting_after, and pagination in a search body).
Coverage
The capability manifest (src/intercom-capabilities.ts) is the real Intercom surface as the
denominator, enumerated top-down from Intercom's own first-party OpenAPI document
(intercom/Intercom-OpenAPI,
descriptions/2.14/api.intercom.io.yaml — 106 paths / 150 operations across 31 tagged areas,
read 2026-08-19) — so coverage is honest and partial. Run
bun scripts/manifest-baseline-one.ts intercom for the live count. As of writing:
done=86 / total=205 (regressions=0).
The version pin is a decision, not a lag
Intercom publishes a description directory per API version, and 2.15 and 2.16 now exist upstream. This pack stays censused against 2.14, deliberately, on four converging facts:
2.14is the version this pack'sspec.sourcerecords and the census fixture is derived from (descriptions/2.14/api.intercom.io.yaml).2.14is what the first-party SDK defaults to —[email protected]'sBaseClientsendsIntercom-Version: 2.14when the caller pins nothing, so an unmodified SDK meets 2.14 shapes.2.14is the version every response shape in this twin was authored against, field by field, from that document's own examples.- Nothing is refused for being newer: the twin accepts 2.15 and 2.16 on the header (and every
version 1.0-2.16 plus
Unstable), it simply renders 2.14 shapes underneath. That gap is named and filed —intercom.protocol.versioned_shapes(todo) — not hidden.
Moving the pin is a deliberate pack revision (re-fetch, re-derive the fixture, re-census, re-author whatever shapes changed), not a bump.
Prioritisation came from a real consumer rather than taste: dub's
Intercom integration drives admins.identify, admins.list, contacts.search, contacts.find,
contacts.create, conversations.create, conversations.reply and messages.create through
[email protected]. Those are the operations modeled first and verified hardest.
Modeled (proven done, each with a failable offline verify):
- Admins —
GET /me(owner + workspaceapp),GET /admins,GET /admins/{id},PUT /admins/{id}/away(away mode folds and comes back off). - Contacts — create / show / update / delete / list,
POST /contacts/search,find_by_external_id,archive+unarchive(archiving genuinely removes the contact from the list and from search while a directGETstill resolves it),merge(a lead into a user). Vendor-faithful details: create answers 200, not 201; a duplicate email+role is a 409 naming the existing id while the same email as a lead is a different contact;PUTmergescustom_attributesrather than replacing them. - Search — the published operator set (
=,!=,IN,NIN,<,<=,>,>=,~,!~,^,$) really filters, nestedAND/ORgroups compose, and the body'spagination { per_page, starting_after }pages the result set. - Conversations — create (answering with the message shape carrying
conversation_id, as the vendor does), retrieve / list / update / delete,search,replyas admin and as contact,parts(close / open / snooze / assign, including theassignee_id: "0"unassign sentinel),customersattach/detach, and conversation tags.openandsnoozeare accepted in the shape the spec documents them — without atypefield — atype: "team"assignment is an honest404 team_not_foundrather than a false "admin-only" claim, and an undocumentedmessage_type(includingaway_mode) is refused rather than fake-succeeded. Stateful: a contact reply reopens a closed conversation and incrementscount_reopens; removing the last customer is the vendor's422. (A redundant close/open is a400here, which is a state-machine reading the vendor does not document either way — filed asintercom.conversations.redundant_transition_semantics, not asserted as vendor behaviour.) - Messages —
POST /messagesin-app and email, optionally opening a conversation (create_conversation_without_contact_reply), updating the recipient'slast_contacted_at. An email without a subject is the vendor's422. - Data events —
POST /events(202 with an empty body, like the vendor),GET /eventstimeline andsummary=trueroll-up, andPOST /events/summaries(200, not 202 — the spec declares different success codes for the two event endpoints, and the verify asserts both). Submitted summaries fold into the summary read and upsert byevent_name, so a later batch never wipes an earlier one. Both the spec's single-objectevent_summariesand the array form callers actually send are accepted (the spec declares an object for a plural bulk field — recorded asintercom.events.summaries_payload_shape, not silently picked). A contact resolves by intercom id,user_idor email. - Data attributes — built-in (non-custom) attributes plus custom-attribute create/update,
custom_attributes.<name>naming, the documenteddata_type: "options"list branch (stored and returned asstring+options, as the vendor's own example shows), and archive/include_archived. Themodelenums differ by surface and the twin follows each: the GET filter acceptsconversation, the create body does not. A built-in is not updatable (422 data_invalid); an unknown id is404 field_not_found. - Tags / notes / segments / subscription types / companies — tag create-or-update, apply and
remove on contacts and conversations, a refusal to delete a tag that is still applied
(
tag_has_dependent_objects); contact notes; segment read surface; subscription opt-in/out; company create-or-update keyed by the caller's owncompany_idwith contact attach/detach. - Protocol — the
error.listenvelope with afieldon validation errors, unmodeled ops404like the vendor,Intercom-Versionaccepted for every version Intercom publishes (1.0-2.16 plusUnstable) and400for a non-existent one — though the twin serves 2.14 shapes regardless (intercom.protocol.versioned_shapes, todo) —readOnly405s writes, cursor pagination that walks the set exactly once, unix timestamps, and id minting that survives dirty state: delete→recreate, create-after-pull, pull-after-local-create, and a pulled admin or data attribute landing on exactly a seeded built-in's id — the real row keeps the contested id and the twin's stand-in is re-homed rather than deleted, so correctness does not rest on the synthetic id band being a lucky guess. (Theper_pagebounds and the unknown-cursor400are the twin's own reading, filed asintercom.protocol.pagination_limits_unconfirmed.) - Connector — pull contacts/conversations/tags/admins/data-attributes (idempotent, and the
cursor walk is bounded),
syncIntercomFromReal, and push that confirms only when the vendor did not answer with itserror.listenvelope. Push sends contact fields through an allowlist: twin bookkeeping (tag_ids,tag_applied_at,segment_ids,event_summaries,archived) can never reach a real workspace, and a write that touched only bookkeeping issues no request at all — an emptyPUTwould be a no-op the vendor still answers200to, which the connector would then wrongly confirm. - UI mirror — inbox list, contacts list, conversation detail, state pills, timestamps and nav, every one data-coupled to real twin state.
Fidelity. src/intercom-sdk.integration.test.ts drives the real, unmodified
[email protected] over HTTP at the twin. The only thing configured is baseUrl, the SDK's
own documented public option; the Intercom-Version stays the SDK's default 2.14, which is
exactly the version this twin models. Two places the test has to widen a generated TYPE are
SDK gaps, not twin gaps, and are called out in-line: CreateDataAttributeRequest omits name
and model, and events.list() is typed as the summary item shape even though the non-summary
response is data_event_list (recorded as intercom.events.list_response_shape).
Rate budget. Intercom documents 10,000 API calls/minute per app and 25,000/minute per
workspace, enforced in 10-second sub-windows, with X-RateLimit-Limit/-Remaining/-Reset and a
429. The pack declares 600 weighted units / 60s — 6% of the per-app limit, deliberately far
under it because the workspace limit is shared with every other installed app and because a
60s rolling window bounds the average rather than the burst. Message-delivering endpoints
(POST /messages, POST /conversations, POST /conversations/{id}/reply) cost 5 because they
reach a real human; POST /events and the two search endpoints cost 2. See
src/intercom-budget.ts.
Twin-only control routes
POST /_twin/admins and POST /_twin/segments are twin-only test scaffolding, clearly
namespaced outside Intercom's surface and deliberately absent from the capability manifest:
admins and segments have no vendor create endpoint (they are workspace configuration), so a verify
needs a way to build that state from nothing. Counting scaffolding as coverage would pad the
denominator. A real client never calls them.
What this twin does not run
The Messenger is a hosted browser widget served from Intercom's own CDN with its own realtime
transport, and this pack twins the REST API rather than that bundle. Outbound email / SMS / push
delivery leaves the machine: the twin records the message and the conversation it opens, but puts
mail in nobody's inbox. Fin's answers come from Intercom-hosted retrieval and model inference, and
placing real calls needs carrier infrastructure — the call resources remain in scope as todos.
Uploaded attachments live on Intercom-operated storage behind signed URLs; the twin models
attachment metadata on a part. Intercom's proprietary reporting metric definitions (what counts
as a first response, which SLA calendar applies, how CSAT is weighted) are unpublished; the
computable part is filed as intercom.reporting.response_times, and conversation statistics over
local state are already modeled.
Everything else not listed above is in scope and simply not built yet — see the todo entries
in the manifest (tickets, articles + help center, news, visitors, custom objects, AI content,
teams, calls, exports, webhooks and the OAuth token exchange).
