Hub / Connectors / Fin · Intercom

Fin / Intercom

A gateway-class connector: TSANet hosts and operates the integration, because Intercom will not run member code inside the workspace. This page is the architecture as built, and the platform behaviour an implementer needs to know before starting.

Class Gateway adapter Status Early access Surface Canvas Kit sidebar, Help Desk Last verified 2026-08-21 Updated 2026-09-13 Repo tsanetgit/Fin-Intercom_App (private during early access)

Connector class

Why this connector is shaped differently

Every connector TSANet shipped before this one assumes code can run inside the member's own system. Salesforce and Dynamics allow it; Zendesk partly; ServiceNow would, though nothing is built there yet. Intercom does not — and neither will most of the platforms behind it. That single constraint makes TSANet the operator of the integration rather than the author of a specification someone else implements.

Zendesk sits between the two: its apps run client-side in a sandbox and its integration flows are Zendesk-hosted, so some logic has a home in-tenant but arbitrary server code does not.
 In-tenant connectorGateway adapter
PlatformsSalesforce, Dynamics (ServiceNow would fit here; not built)Intercom / Fin, Pylon, DevRev, Wolken
Where code runsInside the member's own tenantA service TSANet hosts and runs
Who operates itThe memberTSANet
Credentials liveIn the member's tenantWith TSANet, one set per member
Case lifecycle stateThe CRM holds itThe gateway must hold it
Connector faultsIsolated to one memberCorrelated across members — one hosted service, a single replica in the reference deployment
Platform outageAffects everyone either way — the Connect API is a shared dependency
DistributionInstalled package or appConnected app plus a hosted service

The two failure rows are worth reading together. Every member already depends on the Connect API, so a platform outage stops collaboration whichever class they are in — an in-tenant package does not keep working when the platform is down. What a gateway changes is that connector faults become correlated rather than independent, which is a design input rather than a drawback: it is why the gateway is designed to a higher availability target than any single member's install — and, with the reference deployment a single replica today, why that target is met through durability and recovery rather than replication, and why so much of the design below is about exactly that rather than features.

Three differences that force the change

  1. 01

    There is nowhere in the member's system to put connector code

    Intercom offers an API, webhooks, sign-in, and panels rendered from your own servers. There is no equivalent of an installed package running inside the customer's instance, so everything runs on infrastructure TSANet operates.

  2. 02

    The unit of work is a conversation, not a record

    Other connectors assume a case record with a rich status model, custom fields, and a platform-maintained audit trail. Intercom's native object is a conversation thread; tickets were added later and carry only a handful of states. State other connectors get for free has to be kept by the gateway instead.

  3. 03

    The AI answers first, and a human is the escalation path

    Fin is designed to resolve before a person is involved, and Intercom charges per resolution rather than per seat. A collaboration request arriving into that world is expected to be answered, not queued — and the TSANet lifecycle assumes a human picks the case up.

What the lifecycle actually permits

The open design question was whether an incoming partner request could be answered and closed out without ever becoming a ticket — the flow Intercom is built around. Probed against a live environment rather than reasoned from documentation:

partner submits a request 200 status OPEN, response clock starts
we answer with a note 200 allowed without accepting
partner tries to close it 422 "OPEN cases cannot be closed."
we try to close it 422 same refusal — not a permissions issue

Half of it works. Answering requires no change to the case's status at all, so an AI agent can do its entire job inside a conversation, with nothing written back but the answer. But no case can be retired while it is still open, by either party. Every request that ends cleanly must first be accepted.

The permitted lifecycle for a collaboration request A request moves from Open to Accepted to Closed. While it is Open, a conversation loop runs on the Open state: partner notes come in and Fin's answers go out, over any number of rounds, without changing the status. A direct transition from Open to Closed is refused with HTTP 422. the conversation loop — any number of rounds notes in, Fin's answer out · status stays OPEN OPEN ACCEPTED CLOSED accept close 422 — refused for both parties
The refused edge is the constraint the design has to accommodate: acceptance is not optional, so it cannot be treated as the moment work becomes real. Incoming requests land as a conversation and become a ticket only when a person needs to own the work — tying ticket creation to acceptance would create a ticket for every single request, which is precisely what the conversation-first model exists to prevent.
Recommended guardrail: the AI drafts, a person commits. Fin composes the answer and posts it. Accepting, declining, and requesting more information stay with a human. The reasoning is not answer quality — it is that these actions are contractual commitments to another vendor made under a service-level clock, and that partner escalations carry end-customer logs and text pasted in verbatim: untrusted content reaching an AI that then replies under the member's name.

Topology

The system at a glance

One headless Spring Boot process sits between the two platforms. Everything TSANet-facing lives in an embedded gateway-engine shared across adapters — the Connect Gateway, which this connector was the proving ground for and now consumes as a published artifact; everything Intercom-facing goes through a REST gateway. The process border is the trust boundary: signature verification is default-deny, so a route matching no scheme is rejected 401, and a missing secret fails closed. /healthz is the only open route; the TSANet-internal admin plane under /admin/* is a fourth signed edge, under CONNECTOR_ADMIN_API_SECRET.

Intercom Help Desk (agent) "TSANet Connect" sidebar panel Canvas Kit · ticket + conversation markdown links + bold only, no HTML agents never see the service itself Intercom platform canvas POSTs + webhook delivery weak contract: 5s timeout, 1 retry, 429 throttling → fast-ack + queue Intercom REST API tickets · admin notes · ticket states returns silent 200s on bad fields: every write is read back connector process · Spring Boot · single replica border = signature verification · default-deny HMAC · /healthz only open route Canvas flow CanvasController · CanvasKit FlowStateStore · bounded + TTL stored_data carries {flow_id} only ≤1 gateway call per canvas response gateway-engine (embedded) SdkTsanetGateway MemberSessionRegistry generated Connect client deadlines · sanitization · sessions shared across adapters Webhook intake IntercomWebhookController TsanetWebhookController · v2 only DedupeStore · LRU 10,000 ids always 200: 4xx would kill retry EventQueue bounded · offer() drops on full one drain thread, deliberately Event handlers InboundRequestHandler claim-before-create InboundClosureHandler platform first, then record PartnerResponseHandler once per status transition AnnotationHandler NONE/DEGRADED/FULL · 5s/20s ×3 ReconciliationPoller re-drives what push dropped updatedAt cursor − 120s overlap advances only on a clean cycle Durable state SubmissionLinkTable · never evicted keys: token · internalCaseNo · conv PollerStateStore · same store durable volume required RestIntercomGateway annotate · resolve · note, from the drain only, never a canvas response TSANet Connect API · v1/v2 entity search · process form createCollaborationRequest approve · reject · request-info closure · list (internalCaseNo) OAuth (Entra) or password login no idempotency key on create problem+json via Accept opt-in prod: connect2.tsanet.org Connect webhook push CloudEvents v2 · v1 sunset 2027-01 request.created · request.closed note.created · response.created response.updated at-least-once · may duplicate Partner member works the same case from their own connector or the web app clicks · input 1 2 3 4 5 search · form · submit ↴ dedupe → queue drain fetchByToken · approve/reject/RFI record · anchor · surfaced status listInboundUpdatedSince handle() direct · bypasses the queue cursor → case events
The runtime as built. Accent edges cross the signed process boundary and are numbered in the legend below; plain edges are in-process calls. The green store is the only state that survives a restart.

Flow 1 · outbound

An agent files a case with a partner

The canvas flow keeps all real state server-side (stored_data carries an opaque {flow_id} only), makes at most one gateway call per canvas response, and never retries in-handler: the canvas latency budget is hard — an 8s response renders, a 10s one fails, and a slow submit strands the agent on a spinner with no error at all. The flow id doubles as the submission's internalCaseNumber, one per flow by construction.

Panel Connector gateway-engine Connect API /v1 Intercom API POST /canvas/initialize · signed · ticket key rides every ticket-surface request home canvas · stored_data = {flow_id} /canvas/submit · search "q" searchPartners(q) · deadline 1500ms, no retries entity search · bearer per request (registry session) List<PartnerHit> partner list canvas select partner form(partner) · fresh per flow, never cached fetch process form form canvas · STRING/EMAIL/PHONE/INTEGER/URL→input · TEXT→textarea · HIDDEN unrendered submitter identity prefilled from acting teammate, editable submit form (typed values win over defaults) flow state DRAFT → SUBMITTING (CAS) · flow_id = internalCaseNumber submit(draft) · deadline 4000ms total createCollaborationRequest · testSubmission per config (default true) 200 + case token (no idempotency, no conflict rejection) record SubmissionLink durably · state → SUBMITTED success canvas async, on the drain: annotate ticket attributes, read back · NONE/DEGRADED/FULL · retries 5s/20s ×3
Happy path. Dashed arrows are responses. The annotation write never happens inside a canvas response — the latency budget forbids Intercom calls there; it rides the event queue instead. Attributes are keyed by name, datetimes take epoch seconds, and a workspace-side rename surfaces as the read-back failing, not as a write error.

Flow 1a · submit outcomes

What a submit can become, and why ambiguity is parked

Case submission is not idempotent and the Connect API offers no idempotency key: createCollaborationRequest documents 200/400/401/403 and no conflict status, so a second POST with the same internalCaseNumber simply creates a second case. The gateway contract therefore splits failures into exactly two kinds, and treats everything ambiguous as ambiguous.

submit(draft) ≤ 4000ms total 200 + token Created record SubmissionLink → SUBMITTED annotation event queued for the ticket parsed 400 · 401 · 403 · 404 · 422 GatewayException · definitive rejected before create → back to DRAFT problem+json detail rendered next to the field that fixes it (email-domain rule) timeout · 5xx · reset · token-less 200 GatewayTimeoutException · ambiguous flow parked UNRESOLVED · exempt from store eviction and TTL (severing one orphans the case) reconcile findSubmission(flow_id) best-effort lookup, NOT an idempotency guarantee found → SUBMITTED case confirmed none → DRAFT re-armed >1 match → throws (no silent confirm)
The UNRESOLVED → DRAFT transition on an empty lookup is the server-side marker that re-arms retry; component ids from the client are never trusted for it. An empty lookup cannot prove the original request is not still in flight, so the residual duplicate window is accepted and named in the agent-facing copy. The same taxonomy gates the inbound response actions — a 5xx on an approve read as "definitively rejected" would invite answering a partner twice.

Flow 2 · inbound

A partner opens a case with us

Push is thin and delivered at-least-once, so intake fast-acks and the fetch is the truth: the event contributes only the token. Ticket creation is claim-before-create, arbitrated by the token's UNIQUE constraint in the link table, because check-then-create is a TOCTOU race that has produced duplicate-ticket defects in an earlier connector (note.created overtaking collaboration-request.created).

Partner TSANet Connect Connector Intercom API Agent (panel) opens a collaboration case naming our member CloudEvent collaboration-request.created · signed · thin payload (token; CloudEvent id = only idempotency key) verify → v2 parse (unroutable refused, still 200) → dedupe → queue 200 fast-ack, every branch (4xx is never retried by TSANet) drain: fetchByToken(token) · the fetch is the truth direction INBOUND? (UNKNOWN never guessed) · terminal?CLOSED/REJECTED → skip, never create-resolved record() claim · token UNIQUE arbitrates · only the insertwinner creates · failed create releases claim (unless anchored) create Back-office ticket · configured type · synthetic partner contact id (API handle) + ticket_id (agent-searchable number) caseNumber = ticket_id, captured at create · anchor() write-once panel renders from recorded CaseState: Accept · Decline · Request information Accept · engineer identity prefilled, editable (registered-domain rule) CAS state first: two open panels cannot both answer approve(token, engineer, caseNumber, nextSteps) · via engine, ≤4000ms · 5xx = ambiguous, state stays moved partner sees the acceptance + our searchable case number later: collaboration-request.closed → resolve ticket FIRST, then record CLOSED (a failed resolve records nothing, so redelivery can fix it) response.created / .updated (our outbound cases) → fetch status → surface once per transition: ticket state + admin note
Inbound create and answer. Inbound links carry inbound:<token> as their internalCaseNumber, a local provisional convention. An unset ticket type refuses to create; a blank anchor id is refused in both stores so the release guards agree by construction. One ticket can anchor several cases, so byTicketId refuses a multiple match.

Flow 3 · recovery

Re-driving what push dropped

Between an at-least-once push that can still be lost and a drain that catches internally, anything lost in the middle used to be lost permanently. The reconciliation poller closes that gap: it lists INBOUND cases via listInboundUpdatedSince(cursor − 120s) and drives anything with no link back through the same claim-before-create path.

  • Cursoring on updatedAt was verified, not assumed: a live-created case came back with updatedAt == createdAt to the nanosecond, and a filter set 60s earlier returned it. A naive reading of the data suggested updatedAt only moved on modification, which would have missed new cases for 24h.
  • It calls the handler directly, not through the event queue: a recovery batch offered to a bounded queue could evict live webhook events. The claim arbitrates between the poller thread and the drain exactly as between two deliveries.
  • Success means an anchored link, not a row existing: an unanchored row reads as in-flight and holds the cursor.
  • The cursor advances only after a fully clean cycle, and only to an updatedAt actually observed. An empty window advances nothing. A wedged cursor degrades to a widening query and loud logs, never lost work.
  • Terminal cases are prefiltered (still advancing the cursor), so a missed create plus a partner close cannot manufacture a dead pending ticket. The prefilter runs before the claim lookup, so an orphaned claim on a terminal case cannot wedge the cursor.
  • Orphaned claims are surfaced, not reclaimed: age-based reclaim risks pulling a claim from under a live-but-slow create, recreating the duplicate-ticket class. Releasing one stays a human action.
  • The cursor is persisted before the cycle is declared healthy, in the same durable store as the links — so a cursor cannot outlive the links it cursors over.
  • Bounded windows: the list endpoint has no pagination, so the window is the only load control; the first run asks for now − 24h, never epoch. Single instance only.

What survives a restart, and what deliberately does not

StoreSurvives restartNotes
SubmissionLinkTableyes · requiredThe token ↔ ticket routing substrate; indexed by token, internalCaseNumber and conversation id; never evicted. Carries CaseState, annotation state, case number and surfaced status.
PollerStateStoreyesReconciliation cursor. A lost cursor costs one wider re-scan; lost links orphan every open case — which is why only the cursor is allowed an in-memory fallback.
connect-sdk.db (SDK cache)yesThe embedded engine's operational cache: case summaries, note text, response details, webhook receipts. A cache, not the source of truth — the panel and the poller re-fetch by token — which is what makes the optional retention sweep safe: CONNECTOR_SDK_CACHE_RETENTION (default off, per deployment) evicts terminal cases and their child rows past the window; OPEN cases never age out. Backed up with the link store (5-minute latest copy plus hourly snapshots on a 48-hour window), so evicted content is gone from every store at most 48h after the sweep.
FlowStateStoreno · by designCanvas flow state. SUBMITTING and UNRESOLVED flows are exempt from eviction and TTL; an expired flow restarts cleanly from the home canvas.
DedupeStorenoIn-memory LRU, 10,000 ids. Replay past the bound is re-processed; accepted because downstream handling is idempotent anyway.
EventQueuenoBounded; offer() drops on full. Both annotation offer sites audit the failure with the token so the manual re-drive path holds.
MemberSessionRegistrynoPer-member session, gateway and API client pairing, with a credential fingerprint and rebuild-on-change. The API client is stateful; sharing one across members would bleed credentials.
Deployment answer, probed rather than assumed. SQLite write-ahead logging cannot run on a network-filesystem mount — probed live on Azure Files SMB, where it fails loudly at the very first connection. The reference deployment therefore keeps the database on replica-local storage and pairs it with a backup loop to durable storage (copied every few minutes, restored on boot), and the production profile still refuses to start on a memory store, a relative path or a fake gateway — and a deployment missing SPRING_PROFILES_ACTIVE=prod gets none of those refusals and no signal that it is running on sand. A backup loop is not a database: worst case a restart loses the last few minutes of link writes, which is acceptable for a pilot and not for production — production replaces the file with a managed database, by design a configuration swap rather than a rewrite.

Vocabulary

Connect case status → Intercom ticket state

Connect statusIntercom ticket stateWhy
OPENSubmittedThe partner asked; nobody here has answered yet.
ACCEPTEDIn progressWe took it.
INFORMATIONWaiting on customerBlocked on the partner. Intercom's external label reads "Waiting on you" — cosmetic on Back-office tickets today.
REJECTEDResolvedTerminal. Ticket state alone cannot distinguish declined from finished; the distinction must live on a custom attribute.
CLOSEDResolvedTerminal.
PENDINGACTIONnever movedThinly documented — a bare enum member in the spec; the API page reads it as a case awaiting an action from your side. What this adapter should do with it is an open question.
UNKNOWNnever movedAn unrecognised wire value by construction. A stale state is visibly wrong; a guessed one is invisibly wrong.

Platform notes

What an implementer needs to know about Intercom

Measured against a free Intercom development workspace (US region) and the TSANet BETA environment. Development workspaces are free, do not expire, and the Tickets API is available in them — but agents need an Inbox seat before a canvas app renders for them, which is free in a dev workspace and not obvious.

Surface and placement

API behaviour traps

Events

Permissions

Verified live against a development workspace (2026-08-20/21) with reduced-grant probe apps, not read off a permissions page. The connector's standing minimum: Read tickets, Write tickets, Read and write users (contact search), Write users and companies (despite the name, the scope that creates and updates contacts — the synthetic partner contact needs it once), and Write conversations. Intercom additionally grants four greyed, un-uncheckable baseline scopes to every app, so reads of segments, companies, tags and admins succeed regardless and are not a misconfiguration.

Object model and tenancy

Vendor naming. The company renamed itself Fin in May 2026; the helpdesk product is still called Intercom, and the developer platform is documented as the Intercom and Fin Developer Platform. Expect all three names in the official documentation.

Environments

Hosts, events, and versions

Connect API environments

Credentials arrive through TSANet staff until a member is on the self-service integration portal, which is in the September 2026 release.

EnvHost
Betaconnect2.tsanet.net
Prodconnect2.tsanet.org

testSubmission stays a per-deployment config flag, default true: creating real partner cases is a deliberate opt-in at pilot time. Note the flag asymmetry — you write testSubmission and read testCase.

Webhook event types (v2)

  • collaboration-request.created → inbound ticket create (claimed)
  • collaboration-request.closed → mirror partner close
  • collaboration-request.response.created → partner verdict
  • collaboration-request.response.updated → partner verdict
  • collaboration-request.note.created → notes slice

All prefixed org.tsanet.connect.. Build against v2 only: the v1 subscription and list paths sunset on 2027-01-01 — inside this connector's first year — while the v1 case paths stay with no clock, and v1 carries no event-level idempotency key at all.

Since the last verification

Recent changes, and what is tracked next

Landed (August 2026)

  • First full design-level security review against the connector security taxonomy (supply chain, credential lifecycle, authentication boundaries, data flow and retention, audit, untrusted-content rendering), with the authentication boundary live-probed. Findings are filed as security-labelled issues in the repository; several of the items below are its outcomes.
  • Hardened deployment profile: no webhook topics (verified functionally free), tickets-only mode, verified-minimum grants with a documented provision-then-revoke step — all in the deployment skill's intercom-setup.md.
  • SDK-cache retention sweep: age-based eviction of terminal-case content on a per-deployment window, default off (see the persistence table). The eviction itself now lives in connect-library (since 0.2.0); the connector keeps only the schedule and the window, and its interim hand-rolled sweep is retired.
  • Backups gained history: hourly dated snapshots on a 48-hour window beside the 5-minute latest copy, with a delete-first restore procedure that survives a snapshot lacking a WAL file.
  • Build provenance: the deployable image is built in CI from a repository commit and pushed with a secretless, registry-scoped identity (no admin credential); images are addressed by digest. Dependency updates are automated; workflow actions are SHA-pinned.

Tracked next (not promises)

  • Registry pull by managed identity and retirement of the registry admin credential — the live-touching half of the provenance work.
  • Notes in both directions and status sync still wait on estate-wide decisions shared with the Zendesk connector: a note summary convention, severity mapping, and a platform direction field on notes.
  • Outbound closure policy — see Known gaps.

Evidence standard: everything under "Landed" is merged and was probed; everything under "Tracked next" has an open issue and no merged code.

Honesty section

Deliberately not built, and known gaps

  • Outbound closure: an agent resolving the ticket does not close the partner's case. This needs a policy decision on which Intercom state transitions propagate — /closure documents only 200/400, so a wrong guess is indistinguishable from any bad request.
  • Status sync: the poller recovers missing tickets, not missing state.
  • Notes in both directions, and the information-response reply leg, land with the notes slice; note.created drains unclaimed today.
  • Multi-case tickets render no panel: byTicketId correctly refuses to pick between several cases, but the agent currently sees nothing at all. A "this ticket has N collaboration cases" panel is the follow-up.
  • Inbound ambiguity is transient: an ambiguous response action tells the acting agent, but the recorded state has moved, so a later render shows a cleanly answered case. Outbound has UNRESOLVED for this; inbound has no equivalent yet.
  • Dedupe strategy beyond the CloudEvent id is a later decision; the reconciliation poller remains the source of truth in both directions.
  • PENDINGACTION is thinly documented in both contracts; what this adapter should do with it is an open question for TSANet.
  • Attachments are not covered on this page. Whether this adapter implements the contract's forwardAttachments and AttachmentDeliveryHandler has not been verified; see moving files between members.
  • Orphaned claims hold the poller cursor loudly and indefinitely until a human releases them. Alert on consecutive held cycles, not on consecutive failures.

Build with an assistant

The deployment skill, and how to load it

The connector repository — private during early access, with access granted at onboarding — ships an agent skill — skills/fin-intercom-deploy — that carries this page's operational knowledge in a form an agentic coding assistant can apply while it works on your deployment: the invariants that produce specific defects when violated (one live adapter per member, single replica, a link store that survives restarts), the Intercom workspace setup — the private app's canvas and webhook URLs, the webhook topic list (empty today, and why), the verified-minimum permissions and the hardened profile, the Back-office ticket type with its three exactly-named attributes, the per-workspace ticket-state ids — the TSANet-side provisioning, webhook subscription and go-live flag, and the environment table with a verification checklist — CONNECTOR_SDK_CACHE_RETENTION and CONNECTOR_TICKETS_ONLY, both documented on this page, were added to that table on 2026-09-14 after this page found them missing. It is plain markdown: a SKILL.md that routes, plus three reference files it points into. Every skill TSANet ships, and how to load one into any assistant, is collected on Agentic usage with TSANet Connect.

Loading it

Claude Code — copy the whole folder into your deployment project's skills directory; the references must ride along with the SKILL.md that cites them:

git clone --depth 1 https://github.com/tsanetgit/Fin-Intercom_App.git
cp -R Fin-Intercom_App/skills/fin-intercom-deploy  your-project/.claude/skills/

Other assistants — attach SKILL.md and the references/ files as project instructions; nothing in it is Claude-specific. The skill lives in the connector repository and evolves with it, so refresh your copy when you update the connector.

Typical first steps

Boundaries worth keeping. Member credentials, Intercom client secrets and access tokens never go into an assistant session — the skill is written against placeholders and never needs a real value. And it is distilled guidance, not the source of truth: it names the repository's own files as the authority (the configuration table defers to application.yml, the reasoning to docs/decisions.md), and where they seem to disagree, the repository is newer.