# Intercom

Source module: `app/connectors/intercom.py`. Kind: **both** (claims and facts). Display name: "Intercom (Fin)".

## Purpose

Pulls Fin AI Agent outcomes as **claims** (one resolution claim per conversation Fin marks as resolved) and the conversation record as **facts** (customer messages, human replies, assignments, closes/reopens, conversation ratings) so that the `intercom_fin_resolution` and `intercom_fin_procedure_handoff` contracts can be evaluated.

## Auth and scopes

| Item | Value |
|---|---|
| Method | Access token (Intercom OAuth app token or workspace access token), sent as `Authorization: Bearer`; API version header `Intercom-Version: 2.11` |
| Credentials stored (encrypted) | `access_token`; optional `base_url` (defaults to `https://api.intercom.io`) |
| Least-privilege scopes | **Read conversations**, **Read contacts**, **Read admins**, **Read conversation ratings**. No write scopes. |
| Connection test | `GET /me` (workspace name is recorded in `health`) |
| Non-secret config | `contract_id` (default `intercom_fin_resolution`), `unit_price` (default `0.99`) |

## Fields pulled and why

| Field | Used for |
|---|---|
| `conversation.id`, `created_at`, `updated_at` | Subject id, opening message timestamp, incremental watermark |
| `conversation.state` | (read; not currently stored) |
| `conversation.source.type`, `source.delivered_as` | Channel (`email` when the source type is email, otherwise `delivered_as` or `chat`) for channel exclusions |
| `conversation.ai_agent.resolution_state`, `resolved_at`, `last_answer_type` | Whether Fin counted a resolution (claim), and its timestamp |
| `conversation_parts[].part_type`, `author.type`, `assigned_to.type`, `created_at`, `id` | Fact classification: customer message vs human reply vs bot message, escalation, close, reopen |
| `conversation_rating.rating`, `created_at` | CSAT fact (1–5 scale) |
| `contacts.contacts[0].email` | HMAC-hashed into `customer_hid` for cross-source identity resolution |
| Assignee (via assignment parts) | Escalation detection |

Conversations are fetched with `display_as=plaintext` when parts are missing from the search result, so that part authorship can be read; message bodies are **not** stored.

## What is hashed / never stored

- Contact email → `HMAC-SHA256` with the tenant key → `attributes.customer_hid`. The email itself is discarded.
- Message bodies, contact names, phone numbers, custom attributes, tags, and any other conversation content are not persisted.
- The access token is stored only as AES-256-GCM ciphertext.

## Incremental sync and watermark

- `POST /conversations/search` with `updated_at > <watermark as epoch seconds>` (0 on first sync), `per_page: 150`, cursor pagination via `pages.next.starting_after`.
- Each conversation is fully ingested on every appearance; upserts are idempotent (`claim_id` / `fact_id` derived from `sha256(tenant_id | raw_ref)`), so re-syncs update rather than duplicate.
- Watermark = the maximum `updated_at` seen, stored as `connections.watermark`; the next sync starts from it. A manual backfill (`POST /v1/connections/{id}/sync {"backfill_days": N}`, max 400) temporarily moves the start back. The initial sync after credentials are saved backfills 90 days.

## Rate-limit handling

Shared `HttpClient`: on `429` or any `5xx` it waits `Retry-After` if present, otherwise `min(60, 2^attempt)` seconds, up to 6 attempts, then fails the sync with `gave up after retries`. `401`/`403` fail immediately with `reauth = true` (see RUNBOOKS.md → Connector auth failure). Request count is recorded in `health.requests`.

## Fact types produced

Subject kind: `conversation`. Every fact carries `customer_hid` and `channel` in `attributes`.

| `fact_type` | When | Extra attributes | `raw_ref` |
|---|---|---|---|
| `customer_message` | Conversation source message; any `comment` part authored by `user`, `lead` or `contact` | `part: "source"` for the opener | `intercom:conversation:{id}:source`, `intercom:part:{id}:{part_id}` |
| `human_reply` | `comment` part authored by `admin` | `author: "admin"` | `intercom:part:…` |
| `agent_message` | `comment` part authored by `bot` | `author: "fin"` | `intercom:part:…` |
| `escalation` | `assignment` / `assign_and_reopen` part assigning to an `admin` | `to: "human"` | `intercom:part:…` |
| `status_change` | `close` part → `from: <last state or open>, to: closed, actor: customer|bot|agent`; `open`/`reopen`/`assign_and_reopen` part → `from: closed, to: open, actor: <author>` | `from`, `to`, `actor` | `intercom:part:…`, `…:sc` |
| `reopen` | `open` / `reopen` / `assign_and_reopen` part | `by: <author type>` | `intercom:part:…` |
| `csat_response` | `conversation_rating.rating` present | `score` (1–5), `scale: 5`, `sentiment` (≥4 positive, ≤2 negative, else neutral) | `intercom:rating:{id}` |
| `customer_confirmation` | `ai_agent.resolution_state == confirmed_resolution` | `method: "fin_confirmed"` | `intercom:fin_confirmation:{id}` |

## Claims produced

| Condition | Claim |
|---|---|
| `ai_agent.resolution_state` in `confirmed_resolution`, `assumed_resolution`, `resolved` | `outcome_type: resolution`, `claimed_at = ai_agent.resolved_at` (falls back to `updated_at`), `unit_price` from config, `channel`, `attributes: {resolution_state, customer_hid}`, `raw_ref: intercom:fin_outcome:{id}`, `contract_id` from config |

## Subject id format

`intercom_conv_{conversation_id}` for both claims and facts.

## Known limitations

- Only resolution claims are produced. Procedure-handoff outcomes (`intercom_fin_procedure_handoff`) must currently be supplied via CSV upload.
- `conversation_rating.created_at` may be absent; the connector then uses the conversation's `updated_at` as the rating timestamp, which can be later than the actual rating.
- The connector does not store message text, so transcript assist cannot use Intercom conversations unless the tenant also uploads a CSV with `keep_text`.
- Assignment to a team (not an admin) is not treated as an escalation.
- Fin's own "returned for further assistance" deduction is not read from Intercom; reopen facts are evaluated by the contract instead.
- Voice channels are not distinguished beyond `source.delivered_as`; the library contract excludes `voice`/`phone` by channel name.
