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.
Connector class
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.
| In-tenant connector | Gateway adapter | |
|---|---|---|
| Platforms | Salesforce, Dynamics (ServiceNow would fit here; not built) | Intercom / Fin, Pylon, DevRev, Wolken |
| Where code runs | Inside the member's own tenant | A service TSANet hosts and runs |
| Who operates it | The member | TSANet |
| Credentials live | In the member's tenant | With TSANet, one set per member |
| Case lifecycle state | The CRM holds it | The gateway must hold it |
| Connector faults | Isolated to one member | Correlated across members — one hosted service, a single replica in the reference deployment |
| Platform outage | Affects everyone either way — the Connect API is a shared dependency | |
| Distribution | Installed package or app | Connected 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.
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.
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.
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.
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:
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.
Topology
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.
POST /canvas/initialize and /canvas/submit, signed X-Body-Signature, HMAC-SHA256 over the raw body with the Intercom app client secret.POST /webhooks/intercom, signed X-Hub-Signature, HMAC-SHA1 (sha1=<hex>), same client secret. Note the two schemes differ in both algorithm and prefix. Today this edge carries nothing: no handler consumes an Intercom event, so the reference deployment subscribes no topics (verified 2026-08-20 — a full inbound E2E ran green with the list empty). The intake stays signature-gated for the day a feature needs a topic.POST /webhooks/tsanet, CloudEvents v2, signed x-hub-signature-256, HMAC-SHA256. Non-routable bodies are refused loudly but still answered 200./v2/collaboration-requests/list reads through the embedded engine; the subscription itself is registered at setup on POST /v2/webhooks (and deleted on the v1 path, the only delete there is), and the inbound push it receives is CloudEvents v2 — with the bearer injected per request by interceptor; OAuth pre-refreshes on a 30s expiry skew. Default deadlines, configurable: 1500ms reads, 4000ms mutations, on virtual threads; the reconciliation list read runs under its own fixed 30s budget. The API mints the subscription secret at creation, so a first-ever deploy is two passes: register with the URL, capture the secret into TSANET_WEBHOOK_SECRET, roll the deployment.Flow 1 · outbound
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.
Flow 1a · submit outcomes
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.
Flow 2 · inbound
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).
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
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.
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.updatedAt actually observed. An empty window advances nothing. A wedged cursor degrades to a widening query and loud logs, never lost work.| Store | Survives restart | Notes |
|---|---|---|
SubmissionLinkTable | yes · required | The token ↔ ticket routing substrate; indexed by token, internalCaseNumber and conversation id; never evicted. Carries CaseState, annotation state, case number and surfaced status. |
PollerStateStore | yes | Reconciliation 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) | yes | The 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. |
FlowStateStore | no · by design | Canvas flow state. SUBMITTING and UNRESOLVED flows are exempt from eviction and TTL; an expired flow restarts cleanly from the home canvas. |
DedupeStore | no | In-memory LRU, 10,000 ids. Replay past the bound is re-processed; accepted because downstream handling is idempotent anyway. |
EventQueue | no | Bounded; offer() drops on full. Both annotation offer sites audit the failure with the token so the manual re-drive path holds. |
MemberSessionRegistry | no | Per-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. |
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 status | Intercom ticket state | Why |
|---|---|---|
OPEN | Submitted | The partner asked; nobody here has answered yet. |
ACCEPTED | In progress | We took it. |
INFORMATION | Waiting on customer | Blocked on the partner. Intercom's external label reads "Waiting on you" — cosmetic on Back-office tickets today. |
REJECTED | Resolved | Terminal. Ticket state alone cannot distinguish declined from finished; the distinction must live on a custom attribute. |
CLOSED | Resolved | Terminal. |
PENDINGACTION | never moved | Thinly 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. |
UNKNOWN | never moved | An unrecognised wire value by construction. A stale state is visibly wrong; a guessed one is invisibly wrong. |
ticket_state as a string, but PUT /tickets/{id} requires a numeric ticket_state_id. The ids are per-workspace and only enumerable under Intercom-Version: Unstable, so resolve them once at configuration time, out of band. Never put Unstable in a runtime path.id; under 2.11 it returns a bare string and the write is silently ignored — so a string-shaped read-back should throw rather than pass.Platform notes
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.
ticket key, which rides every canvas request from a ticket surface, initialize and submit alike.PUT /tickets/{id} with admin_assignee_id returns success and does nothing; the working field is assignee_id. This is why read-back-and-assert is a standing rule.POST /conversations returns a message id, not the conversation id. Reply calls 404 against it. Use the conversation id from a canvas payload or from search.list_items is a comma-separated string. Passing an array returns a 500 — so option labels cannot contain commas.id is the API handle; ticket_id is the number agents see and can search. Send ticket_id to a partner as the case number — a partner quoting the API id sends agents searching for a ticket that does not exist — and use id for API calls.X-Body-Signature, HMAC-SHA256 over the body, bare lowercase hex. Webhooks use X-Hub-Signature, HMAC-SHA1, with a sha1= prefix. Both are keyed on the same app client secret.ticket.created also fires an initial ticket.state.updated.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.
POST /tickets/{id}/reply) ride Write tickets — 200 under a tickets-only token — and only the conversation-reply endpoint needs Write conversations (401 under the same token on the same underlying object). A genuine per-endpoint split, even though tickets are conversation-backed.CONNECTOR_TICKETS_ONLY=true, default off) refuses new collaborations from plain conversations with a static canvas so Write conversations can be dropped; and Write users and companies is revoked after the synthetic contact is provisioned at onboarding (if it is ever needed again, the failure is loud and the poller recovers after re-grant). Resulting standing set: Read/Write tickets plus Read and write users, plus the baseline scopes. Two consequences to accept deliberately: agents lose canvas visibility into pre-existing conversation-anchored collaborations, and partner-response notes for those same links fail loudly in the audit log once the grant is gone — drop it only when no conversation-anchored links are active.Back-office.POST /tickets/{id}/reply with message_type: note, type: admin, and an admin_id. Back-office tickets carry a "Not shared with customer" badge in the UI.Environments
Credentials arrive through TSANet staff until a member is on the self-service integration portal, which is in the September 2026 release.
| Env | Host |
|---|---|
| Beta | connect2.tsanet.net |
| Prod | connect2.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.
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
security-labelled issues in the repository; several of the items below are its outcomes.intercom-setup.md.connect-library (since 0.2.0); the connector keeps only the schedule and the window, and its interim hand-rolled sweep is retired.Evidence standard: everything under "Landed" is merged and was probed; everything under "Tracked next" has an open issue and no merged code.
Honesty section
/closure documents only 200/400, so a wrong guess is indistinguishable from any bad request.information-response reply leg, land with the notes slice; note.created drains unclaimed today.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.PENDINGACTION is thinly documented in both contracts; what this adapter should do with it is an open question for TSANet.forwardAttachments and AttachmentDeliveryHandler has not been verified; see moving files between members.Build with an assistant
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.
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.
application.yml, the reasoning to docs/decisions.md), and where they seem to disagree, the repository is newer.