---
name: conversation-archive-mcp
description: Operator-initiated, MCP-driven transcript ingest for Granola / Otter / Circleback meetings. Sibling of `conversation-archive` for the file-drop path; same writer surface, same parent label, same identity formula. The operator installs each provider's official remote MCP into Claude Code via the provider's documented one-liner (`claude mcp add --transport http <provider> <provider-mcp-url>`); OAuth lives in the host. The skill confirms owner + every distinct attendee against existing `:AdminUser` / `:Person` nodes (email-autoresolve where present, never auto-create), then for each in-scope meeting calls the provider's MCP `list_meetings`-style tool to enumerate, `get_meeting`-style tool to fetch the transcript, renders the transcript into the same turn-attributed `[ISO timestamp] Sender: body` shape that the `memory-ingest` `chat` mode consumes, and writes one `:ConversationArchive` per meeting MERGEd on `conversationIdentity = sha256(accountId + ":" + sortedParticipantElementIds + ":" + source)` with the provider's stable meeting ID stamped as a cross-reference (`granolaNoteId` | `otterConversationId` | `circlebackMeetingId`) and the provider-supplied `calendarEventId` when present. Idempotent on re-ingest (same identity, same chunks → empty-delta noop). Triggers when the operator says "ingest my last Granola meeting", "import the last week of Otter transcripts about Acme", "pull this Circleback meeting", or similar.
---

# Conversation Archive — MCP-driven transcript ingestion (Granola / Otter / Circleback)

**Invoked from `specialists:librarian`** — the admin agent dispatches the librarian when the operator asks to ingest meetings from a transcription provider whose MCP is connected to Claude Code. The librarian loads this skill.

**Sibling of [`conversation-archive`](../conversation-archive/SKILL.md), not a replacement.** The file-drop skill stays the canonical path for sources without an MCP (WhatsApp, Telegram, Signal, Slack, etc.). This skill is the canonical path for the three transcription providers Maxy's ICP pays for — Granola, Otter, Circleback — all of which ship first-class remote MCP servers. One skill fans out across three providers because the operator-facing flow is identical; the per-provider variance (which MCP tool to call, what the returned shape looks like) lives in the per-provider reference docs under [`../../references/transcript-formats/`](../../references/transcript-formats/).

**Shared writer surface.** Every meeting ingested through this skill lands as a `:ConversationArchive` indistinguishable in shape from a file-drop ingest of the same meeting:

- Parent label: `:ConversationArchive` (Task 397).
- Identity: `conversationIdentity = sha256(accountId + ":" + sortedParticipantElementIds.join(",") + ":" + source)`, with `source ∈ {"granola", "otter", "circleback"}`.
- Section chunks attach via `:HAS_SECTION` chained by `:NEXT`, same as the file-drop path.
- Participant cross-edges via `:PARTICIPANT_IN` from the archive to each resolved `:Person` / `:AdminUser`.
- Per-provider stable meeting ID rides as a cross-reference property on the parent: `granolaNoteId`, `otterConversationId`, or `circlebackMeetingId`. Multiple may co-exist if the same conversationIdentity ingests through two providers in sequence (e.g. operator imports a meeting from Otter, then re-imports the same participant set from Granola — the second ingest MERGEs into the same parent and stamps the additional provider ID).
- `calendarEventId` stamped on the parent when the provider supplies it. The MERGE-into-`:Meeting` link is downstream of this skill — see [Task 408](../../../../.tasks/408-conversation-archive-calendar-merge-key.md) for the writer-side change that uses `calendarEventId` as the primary MERGE key when present.

**No bash entry. No per-source normaliser.** The provider's MCP server is the parser. The skill calls `list` and `get` tools the provider exposes, renders the returned shape into turn-attributed text in-turn, and calls `memory-ingest` directly. This is the structural reason the skill is sibling rather than nested — there is no `conversation-archive-ingest.sh` involvement on this path.

## Step 1 — preflight (MCP connectivity check)

Before any operator question, confirm the named provider's MCP is connected to this Claude Code session. The host renders connected MCP servers under tool names matching `mcp__<server>__<tool>`. If the operator names "Granola" and no `mcp__granola__*` tools are visible to this session, FAIL early with the install one-liner from the provider's reference doc:

```
[conversation-archive-mcp] FAIL phase=preflight reason="granola MCP not connected to this Claude Code session"
Install:  claude mcp add --transport http granola <provider-mcp-url>
Then re-run.
```

The install one-liner per provider lives in the reference docs:
- [`../../references/transcript-formats/granola.md`](../../references/transcript-formats/granola.md)
- [`../../references/transcript-formats/otter.md`](../../references/transcript-formats/otter.md)
- [`../../references/transcript-formats/circleback.md`](../../references/transcript-formats/circleback.md)

**Tool name discovery.** The exact MCP tool names each provider exposes are not stable across vendor doc revisions. The skill discovers tools at run time by reading the session's available `mcp__<provider>__*` tool names, matching by documented purpose (one for "list meetings in a window", one for "fetch a meeting's transcript"). The per-provider reference doc names the expected pattern *and* notes that the operator can run the host's `/mcp` command to enumerate the live tool set if discovery fails.

## Step 2 — scope solicitation (one operator question)

Ask the operator one question to bound the ingest:

- **Single meeting.** "Pull the last meeting from <provider>." → resolves to one meeting ID.
- **Date range.** "Import meetings from <provider> between <date> and <date>." → resolves to the provider's `list_meetings` tool with a date filter.
- **Topic / participant filter.** "Import Otter meetings about Acme this month." → resolves to `list_meetings` with the provider's search/filter argument plus a date window.
- **Explicit meeting IDs.** "Ingest these three meeting IDs: …" → skips the list step.

The operator's answer becomes the argument set for the provider's list-meetings tool. Echo back the resolved scope and confirm before the list call: `"Will list <provider> meetings <window>. Confirm yes/no."` Doctrine: operator-initiated only. The skill never auto-syncs, never polls, never repeats on a schedule. Each ingest is one explicit operator request.

## Step 3 — list meetings via provider MCP

Call the provider's documented "list meetings" tool with the operator's filter. The returned shape is per-provider — see the reference doc — but every provider returns at minimum: meeting ID, title/subject, start time, attendee list (each with display name and email when known).

Render a one-line histogram per returned meeting for the operator:

```
[1] <ISO start>  <title>  (attendees: <count>)  [<provider-id>]
[2] ...
```

If the histogram is unexpectedly large (>20 meetings) or unexpectedly empty (0), confirm before proceeding — the filter may have been too broad or too narrow.

## Step 4 — owner + all-attendees confirmation (mandatory before any write)

Same discipline as [`conversation-archive`](../conversation-archive/SKILL.md): every distinct attendee across the selected meetings must resolve to an existing `:AdminUser` or `:Person` elementId before any write. **Never auto-create a `:Person`.** The operator owns the create gate for unmatched attendees.

Resolution order:

1. **Email autoresolve.** For every attendee the provider returned with an email address, run a single `memory-search` against `:Person` and `:AdminUser` matching on the `email` property scoped to this `accountId`. Matched attendees resolve silently — the operator does not see a question for them.
2. **Per-sender prompt for unmatched.** For each remaining distinct attendee (no email, or email did not match), surface one question: `"Attendee '<display name>' (<email or 'no email'>) (<meeting count> meetings) — pick existing :AdminUser/:Person or block?"`. The operator either picks an existing elementId or names "block" (refuses to map; that attendee is omitted from `:PARTICIPANT_IN` but the meeting still ingests if at least one anchor resolves).
3. **Owner echo.** "Owner = `:AdminUser <name> (<elementId>)`; other participants = `<list>`. Confirm yes/no."

**Zero-anchor LOUD-FAIL.** If, after resolution, a meeting has zero participants that resolve to an `:AdminUser` or `:Person`, FAIL for that meeting with:

```
[conversation-archive-mcp] FAIL phase=resolve reason="meeting <provider-id> has no resolved attendees; no graph anchor"
```

The operator either resolves at least one attendee or skips that meeting. Writing an orphan `:ConversationArchive` with no `:PARTICIPANT_IN` edges is forbidden — every node has an anchor in the canonical hierarchy.

## Step 5 — per-meeting fetch + ingest, one meeting per turn

For each selected meeting, in chronological order:

1. **Fetch transcript** via the provider's documented `get_meeting`-style tool with the meeting's provider ID. The returned shape carries: turn-attributed utterances (speaker label + start time + text), title, start/end time, attendee list, and provider-specific metadata (`calendarEventId` when present, recording URL, etc.). The per-provider reference doc names the exact field paths.

2. **Render to turn-attributed text** in the same shape `memory-ingest` mode `chat` consumes:
   ```
   [<ISO timestamp>] <Sender display name>: <utterance text>
   [<ISO timestamp>] <Sender>: <utterance text>
   …
   ```
   Provider quirks (continuation lines, speaker switches mid-utterance, redactions) collapse to one line per utterance. Reference docs name per-provider edge cases.

3. **Compose `conversationIdentity`** from the resolved attendees (sorted `elementId` strings) and the source enum:
   ```
   conversationIdentity = sha256(accountId + ":" + sortedParticipantElementIds.join(",") + ":" + source)
   ```
   Source is one of `granola | otter | circleback`. The same formula as the file-drop path; a meeting that ingests once via this skill and is later re-exported via the file-drop path will MERGE into the same parent only if the file-drop source enum matches — which it will not (file-drop has its own source enum). Cross-provider MERGE within this skill (Granola + Otter recordings of the same Zoom call landing as one parent) requires the [Task 408](../../../../.tasks/408-conversation-archive-calendar-merge-key.md) writer-side change to use `calendarEventId` as the primary key when present. Until that lands, same-meeting two-provider ingest writes TWO `:ConversationArchive` rows — name this explicitly in the operator summary.

4. **Produce typed-section JSON chunking in-turn.** Split the rendered turn text into topic-bounded `:Section` chunks following the standard ontology shape: each section carries `kind` (`Conversation` for a generic chunk, or a typed kind like `Topic` when the conversation explicitly establishes a typed entity), `title`, `body`, `summary` (1-3 sentences, ≤500 chars), `sourceStart` / `sourceEnd` (char offsets into the rendered turn text), `properties` (`firstMessageAt`, `lastMessageAt`, `messageCount` from the section slice), and `anchorEdge: null`. Produce a `documentKeywords` array of 3-8 topic keywords for the meeting.

5. **Call `memory-ingest`** with the conversation-archive envelope:
   - `conversationIdentity` ← computed above.
   - `source` ← `granola | otter | circleback`.
   - `archiveSha256` ← sha256 over the rendered turn text (deterministic per-fetch content hash; supports empty-delta short-circuit on re-ingest).
   - `archiveSourceFile` ← `<provider>:<provider-meeting-id>` (synthetic — no file on disk).
   - `archiveTitle` ← `"<provider> · <meeting title> · <ISO date>"`.
   - `participantElementIds` ← `[ownerElementId, ...nonOwnerResolvedElementIds]` (owner first).
   - `anchorNodeId` ← `ownerElementId`.
   - `anchorLabel` ← `"AdminUser"`.
   - `documentSummary` ← `archiveTitle`.
   - `sections` ← the typed-section JSON.
   - `documentKeywords` ← the topic keywords for this meeting.
   - `lastIngestedMessageHash` ← `sha256(lastUtteranceText)`.
   - `lastIngestedMessageAt` ← the last utterance's timestamp.
   - `scope` ← `"admin"`.
   - **Additional properties on the parent (stamped via the standard property bag):**
     - `granolaNoteId` | `otterConversationId` | `circlebackMeetingId` — set the one that matches the source.
     - `calendarEventId` — set when the provider supplied one. Stamped now; the [Task 408](../../../../.tasks/408-conversation-archive-calendar-merge-key.md) writer-side change will MATCH on this for cross-provider MERGE.
   - `cleanupPriorChunks` ← `false` always on this path. The MCP path has no `--rebuild` semantics; a destructive re-ingest is the operator's call via the file-drop skill if ever needed.

6. **If `memory-ingest` returns an error**, stop the loop and surface the error. Meetings already ingested are durable on the parent's `:ConversationArchive` MERGE; re-running with the same filter resumes from the next un-ingested meeting (the empty-delta short-circuit on re-issue skips already-written meetings).

## Step 6 — three operator messages per ingest

After the last meeting lands, emit three operator-facing lines aggregated across all meetings:

1. **Fetch summary.** `Fetched <count> <provider> meeting(s) (<dateRange.first> → <dateRange.last>). Attendees: <distinct attendee names>.`
2. **Ingest summary.** `Ingested <count> :ConversationArchive row(s), <totalChunks> chunks total. Topics: <unioned documentKeywords>.`
3. **Cross-reference summary.** `Stamped provider IDs: <provider>=<count>. calendarEventId stamped on <n>/<total>. Cross-provider MERGE on calendarEventId pending Task 408.`

For an empty-delta re-import (every meeting already ingested under the same `conversationIdentity` + same content hash): emit only line 1 + a noop line `noop reason="all <count> meetings already ingested (empty-delta on every conversationIdentity)"`.

## Failure paths

- **Preflight FAIL** (MCP not connected): single stderr line, install one-liner, yield. Operator runs the install, re-runs the skill.
- **Provider MCP returns an error mid-fetch** (rate limit, auth expired, network): stop the loop, surface the provider error verbatim, yield. Meetings already ingested are durable.
- **`memory-ingest` returns an error**: stop, surface, yield. Same durability guarantee.
- **Zero-anchor meeting**: per-meeting LOUD-FAIL (Step 4); the loop continues with the next meeting if any have anchors, FAILs the whole run only if every selected meeting has zero anchors.

## Idempotency

- **Re-running with the same filter** that selects already-ingested meetings: each meeting computes the same `conversationIdentity` + same `archiveSha256`; `memory-ingest` returns empty-delta and no chunks are written. The operator sees the noop summary.
- **Re-running with an expanded filter** that includes new meetings: already-ingested meetings noop; new meetings ingest normally. No partial-state risk.
- **Re-ingesting the same meeting after the transcript has been edited in the provider's UI** (rare but possible — operator corrects a name in Granola): the `archiveSha256` changes, so the writer treats it as a delta-append. This may double-write chunks. Operator-issued `--rebuild` is not exposed on this path; the operator handles a true re-classify by deleting the parent via `memory-delete` and re-running.

## Doctrine

- **Operator-initiated only.** No auto-sync, no polling, no scheduled invocation. Every ingest is one explicit operator request.
- **Never auto-create a `:Person`.** Email-autoresolve binds to existing nodes only. The operator owns every create.
- **One skill, not three.** Per-provider variance lives in the reference docs, not in three near-duplicate skills.
- **Provider OAuth lives in the host.** Maxy never holds a Granola / Otter / Circleback token. The operator runs `claude mcp add` and authorises each provider in the provider's own UI.
- **`calendarEventId` is the future primary MERGE key.** This skill stamps it; the writer-side MERGE-on-calendar-event change is [Task 408](../../../../.tasks/408-conversation-archive-calendar-merge-key.md). Until then, cross-provider dedup of the same meeting requires the operator to use a single provider per meeting.

## Verification (post-write)

- `MATCH (a:ConversationArchive { conversationIdentity: $cid }) RETURN elementId(a), a.source, a.granolaNoteId, a.otterConversationId, a.circlebackMeetingId, a.calendarEventId` — `source` matches the provider, the matching provider-ID property is non-null, `calendarEventId` is non-null when the provider supplied it.
- `MATCH (a:ConversationArchive { conversationIdentity: $cid })-[:HAS_SECTION]->(c:Section) RETURN count(c)` — equals the aggregated `sectionCount` from the `memory-ingest` responses.
- `MATCH (p)-[:PARTICIPANT_IN]->(a:ConversationArchive { conversationIdentity: $cid }) RETURN count(p)` — equals the count of resolved attendees for that meeting.

## What this is not

- **Not** a live in-meeting capture. The provider produces the transcript; this skill ingests already-finished recordings. Async, offline only.
- **Not** a file-drop fallback for Granola / Otter / Circleband transcript exports. If a meeting falls outside the provider's MCP retention window, the operator's recourse today is to export the transcript file and feed it to the file-drop `conversation-archive` skill — that path requires a per-provider normaliser to be implemented under `conversation-archive`; not in scope here.
- **Not** an OAuth manager. Tokens live in Claude Code's MCP host, not in maxy.
- **Not** a polling sync. Every ingest is operator-initiated.
- **Not** the writer-side MERGE-on-`calendarEventId` change. This skill *stamps* `calendarEventId`; the writer-side change is [Task 408](../../../../.tasks/408-conversation-archive-calendar-merge-key.md).
- **Not** an insight-extraction pass. Phase 2 derivation (`:Observation` / `:Task` / `:Preference` / `:MENTIONS`) is [Task 433](../../../../.tasks/433-conversation-archive-derive-in-turn.md) and applies uniformly to every source.
