RFC-113: Graph-Aware PKM Export (Obsidian)¶
- Status: Draft — v1 shipped (#1472): Obsidian export (
GET /api/app/export?format=obsidian&since=,app_pkm_export) — id-keyed wikilinked highlight/entity/episode notes undercloselistening/. Incremental: the server tracks a per-user vault snapshot (path→hash) + cursor; a matchingsinceyields only changed notes + aremovedtombstone list, else a full export (fallback). Header metadata (X-Export-*) drives the client cursor. Frontend: an "Export to Obsidian" button in the Library Highlights tab (web; remembers the cursor in localStorage for incremental). Notion (OAuth push) + native-shell zip handling remain the follows below. - Authors: Marko, Claude (Opus 4.8), advisor (Fable 5)
- Related epic: #1472
- Stakeholders: Core team, platform users (PKM/second-brain), Consumer App
- Related PRDs:
docs/prd/PRD-046-delivery-and-curation.md(the arc this extends; the export was its named next arc)docs/prd/PRD-041-consolidation.md(the personal corpus this serializes)- Related RFCs:
docs/rfc/RFC-111-curation-surfaces.md(§3 froze the highlight↔entity serialization shape for this)docs/rfc/RFC-072-canonical-identity-layer-cross-layer-bridge.md(canonical ids → wikilink targets)docs/rfc/RFC-114-personal-corpus.md(Phase 1: the corpus + the revision counter this exports over)
Implementation status (2026-08-06)¶
Built on feat/next-arc-rfcs (unpushed). v1 + incremental + frontend button shipped.
app_pkm_export.pyemits an id-keyed wikilinked vault (closelistening/— Highlights → People/ Topics/Episodes), extractive + bridge-only (no audio, no LLM — D6).- Incremental: a per-user content-hash vault snapshot (
path → hash) + cursor;export_bundle(since)returns only changed notes + aremovedtombstone list whensincematches, else a full export (finer-grained than RFC-114's episode-log — it catches highlight-text/label edits). GET /api/app/export?format=obsidian&since=→ zip +manifest.json+X-Export-*headers.- Player "Export to Obsidian" button (HighlightsView, web-only) with a localStorage cursor.
- Review-hardening (fable-5): YAML frontmatter escapes quotes/newlines; highlight-id + slug run
through the traversal guard before becoming zip paths; full mode sets
replace_namespaceso a fallen-behind client prunes stale notes; vault computed inside the state lock (no reconcile race).
Abstract¶
The podcast-Readwise category (Snipd, Podwise) is a feeder: it exports flat highlights into
the user's PKM, where the user does the connecting. closelistening builds the connected graph. This
RFC exports it as a graph: each highlight becomes a Markdown note that wikilinks to
[[Entity]], [[Guest]], [[Topic]], [[Episode]] notes, so the user's Obsidian vault mirrors
their personal knowledge graph — not a flat dump. v1 is Obsidian-only, a one-way incremental
pull driven by RFC-114 Phase 1's revision counter + change log (so it can express deletions,
not just additions). Notion is explicitly a separate later slice (different mechanism — OAuth
push). The highlight payload shape is already frozen (RFC-111 §3); this RFC adds the vault
contract (layout, filenames, frontmatter, link syntax, tombstones) + the emitter.
Problem Statement¶
Users live in Obsidian. Our value is trapped on our surface (or exportable only as flat Markdown, PRD-040). Competitors feed vaults with disconnected atoms; we can emit the connections because every highlight carries canonical entity ids (#1419) over the shared KG (RFC-072). The gaps: (1) an emitter that turns that into a linked note graph; (2) an incremental mechanism that handles deletions and retroactive membership (a favorite joining an old episode) — which a naive "changed-since-timestamp" cannot; (3) stable filenames that survive label renames and entity merges.
Goals¶
- Graph-carrying export: highlight notes + entity/guest/topic/episode notes, wikilinked.
- Obsidian first (Markdown +
[[wikilinks]]). Notion is a named later slice, not v1. - Incremental, one-way pull over RFC-114's change log: emit adds and tombstones; deterministic, re-runnable, no duplication. A full re-export is always valid (id-keyed overwrite) as the fallback + the v1-if-114-slips path.
- Reuse the frozen RFC-111 §3 shape for the highlight payload; define the vault contract here.
- Extractive, no LLM (D6).
Constraints & Assumptions¶
- Depends on RFC-114 Phase 1 — the export scopes to
experienced ∪ saved(labelled) and its cursor is thecorpus_revision; its deletions come from the change log. This RFC does not invent its own per-surface corpus derivation (that's the drift RFC-114 exists to kill). - One-way v1 — platform → vault. Conflict-free: we own the emitted namespace (
closelistening/); user edits outside it are untouched. - Bridge-only audio — notes carry transcript quotes + deep-links, never audio.
Design & Implementation¶
1. Vault contract (the public artifact — appendix-level detail)¶
Layout (all under a closelistening/ root so re-export never touches the user's own notes):
- closelistening/Highlights/<highlight_id>.md
- closelistening/People/<person_id>.md, Topics/<topic_id>.md, Episodes/<episode_slug>.md
Filenames are canonical ids, never display labels (resolves the earlier draft's contradiction).
Obsidian resolves [[wikilinks]] by filename, so id-keyed names survive label renames and
alias merges (RFC-072 KL2 is future — labels will move). Human-readable labels live in
frontmatter aliases: and in the link's display text.
Link syntax: [[person_ab12|Ada Lovelace]] (id target, label shown).
Highlight note (frontmatter + body):
---
id: h_1a2b3c
episode: acquired-nvidia
kind: "span" # span | moment | insight
speaker: "Jensen Huang"
t_ms: 3921000
captured: 2026-09-18 # YYYY-MM-DD, UTC
color: "amber"
entities: [person_ab12, topic_scaling]
source: user # or "auto" (GI editor's-pick)
aliases: ["“The bottleneck was never compute…”"]
---
> “The bottleneck was never compute; it was our willingness to throw away a working model.”
— [[Episodes/acquired-nvidia|NVIDIA: The Machine…]] · [▶ 1:05:21](https://…/episode/acquired-nvidia?t=3921)
Discusses [[People/person_ab12|Jensen Huang]] · [[Topics/topic_scaling|Scaling Laws]]
## Notes
- the user's own writing about this capture
published / duration_seconds
/ summary_title in frontmatter (the integer, not "6 min": frontmatter is queried, and a rendered
string neither sorts nor compares), then title · length · link, then the summary. It was a title and
a link, which made Episodes/ a folder of stubs: every highlight pointed at a note carrying nothing
the link text did not already have.
All THREE summary fields travel, because the app treats them as three different things (operator
2026-09-18): summary_title is a headline and, per KnowledgePanel, "is not a short summary";
summary_text is the prose the Episode notes panel's Summary section renders; summary_bullets
are the digest under it (Key points). They are rendered in that order — the order the player
presents them.
Entity note is thin (id, label, source) — the graph emerges from backlinks, not duplicated body.
What the vault carries, and what it deliberately does not (operator 2026-09-18). An export is
CONTENT: the words, who said them, what kind of capture it is, when it was made, the user's colour,
what it is about, and anything they wrote about it. App scheduling state — the resurfacing ladder's
count/last_surfaced, and the retired flag behind "stop resurfacing" — does not travel: it
describes how this product nags you, which means nothing in a vault read years later in another
tool. anchor_status is excluded for the same reason: drift matters because the app can JUMP to a
timestamp, and a vault note is a record of what was said.
Five of those were absent until audited against the schema. The costly one was notes:
get_notes() was never called, so a vault kept the podcast's words and dropped the reader's —
the wrong half to lose. Notes on an EPISODE land on the episode note, mirroring where the Markdown
export puts them.
Deep links are ABSOLUTE, exactly as the example above always showed — the emitter shipped a
site-relative /episode/<slug>?t=<s>, which is dead where a vault is actually read: Obsidian
resolves it against the vault, not a website. The origin comes from APP_PUBLIC_ORIGIN
(default https://closelistening.app), not from the request Host, because note content is
hashed to drive the incremental cursor below — a host-derived URL would rewrite every note in the
user's vault the first time they exported from a different origin (native shell, tunnel, localhost).
2. The emitter + incremental cursor¶
GET /api/app/export?format=obsidian&since=<revision>→ a bundle (zip of Markdown + amanifest.json).since= RFC-114corpus_revision. The emitter readsGET /api/app/corpus/changes?since=→ the adds + tombstones; it emits/overwrites notes for adds (id-keyed ⇒ idempotent) and records removed ids in the manifest so the client deletes those vault files.manifest.json:{ from_revision, to_revision, written: [paths], removed: [paths] }— the client applies adds then deletions, giving a vault that tracks the corpus exactly.- Full export =
since=0: emits the wholecloselistening/tree, valid any time (the fallback, and the v1 shape if 114's change log isn't ready — ship full-only, add the delta when the counter is). - Entity-id merges (RFC-072 KL2, future): a merge emits a tombstone for the losing id + rewrites referring highlight notes in the next delta — same tombstone primitive, no special case.
3. Notion (separate later slice — NOT v1)¶
Notion is architecturally different: a zip of Markdown can't carry Notion relations, and Notion has
no client-supplied stable page ids, so idempotency needs a persisted entity_id → notion_page_id
map + an OAuth push integration into the user's workspace. That is a different delivery mechanism
(push, not pull) with its own auth + state — a separate RFC/slice, explicitly out of this v1.
Key Decisions¶
- Wikilinked, id-keyed entity notes — the differentiator; survives renames + merges (labels in frontmatter/display only).
- Incremental over RFC-114's change log (adds + tombstones) — deletions are expressible; full re-export is the always-valid fallback.
- Obsidian-only v1; Notion is a separate push slice — not "same shape, second target".
- Own a
closelistening/namespace — one-way, conflict-free with the user's own notes.
Alternatives Considered¶
- Flat Markdown export (PRD-040 / Snipd shape). The thing we differentiate against; kept as the existing simple export, not this.
- Timestamp cursor. Rejected — can't express deletions or retroactive membership (RFC-114 §Alts).
- Live Obsidian plugin (Snipd-style). Heavier + platform-specific; the pull/bundle works for any vault. A plugin can wrap this API later.
- Two-way sync in v1. Rejected — vault-side conflict resolution is a large separate arc.
Testing Strategy¶
- Unit: highlight-note render (id-keyed links, deep-link, frontmatter, aliases); entity-note dedup;
the
sincedelta emits adds and tombstones; a deleted highlight → aremovedmanifest entry; a label rename → same file, updated alias (no new file). - Integration: export a fixture corpus → a valid vault; re-export at the same revision → empty delta;
a changed/removed highlight → exactly one written/removed path; full export (
since=0) round-trips. - No-audio assertion on every emitted note (bridge-only).
Rollout & Monitoring¶
- Ship Obsidian export behind a flag (full-export first if RFC-114's change log trails; delta when ready). Read-only from the platform side; disabling the endpoint is the rollback.
- Monitor: exports, delta sizes, % highlights with
entities(span highlights can be ref-less per RFC-111 §3 — those notes are flat; carry that metric so we know the flat-fraction we're shipping).
Open Questions¶
- Frontmatter schema stability if two-way sync ever lands.
- Do we export
saved(favorited-unheard) episodes' entities, orexperiencedonly? (Default:experienced;savedbehind a toggle, labelled.) - Orgs: the bridge carries MENTIONS_ORG but
AppEntityRefis person/topic only — orgs absent from the vault v1; confirm intended.
References¶
- RFC-111 §3 (frozen highlight shape), RFC-114 Phase 1 (corpus + revision counter), RFC-072 (canonical ids + KL2 merge), PRD-046 (arc), PRD-041 (personal corpus).