Skip to content

Export & import a knowledge graph

Move a knowledge graph in and out of angelo in portable formats: export a zettelkasten box (or the memory research tree) to GEXF for graph tools like Gephi or networkx, export a box to an Obsidian-style markdown vault, and bulk-import a markdown vault back into the zettelkasten.

All of these ride the existing MCP dispatchers — there is no new top-level tool.

Export a zettelkasten box

Use the graph tool's export action (read-only — available even on a write-free server instance):

  • GEXFgraph(action="export", graph="<box>", format="gexf") returns a GEXF 1.2 document: each note is a <node> (label = title; attributes type, tags, status, source), each link is an <edge> (label = relation, weight = confidence). Open the returned gexf field in Gephi, or load it with networkx.read_gexf.
  • Obsidian vaultgraph(action="export", graph="<box>", format="obsidian", out_dir="<dir>") writes one .md per note under out_dir (default export/<box>-vault/). Each file has YAML frontmatter carrying the note id (plus title/type/tags/aliases/status) and renders every link as a Dataview-style typed wikilink, e.g.:
relation:: contradicts [[Information Ratio]] (confidence:: 0.8)

Conventions that make the round-trip faithful:

  • The id in frontmatter lets a re-import dedup on note identity — so re-importing an exported vault into the same box is idempotent (no duplicate notes), not just into a fresh one.
  • A link's confidence rides in an optional (confidence:: <float>) subfield, a non-default direction in (direction:: <dir>), and a cross-graph target (a link into another box) in (graph:: <box>) — so all three survive export → import.
  • The wikilink target is a note title when that title is unique and free of wikilink-breaking characters ([, ], |); otherwise it falls back to the target note's id, so duplicate-title or special-character notes still resolve to exactly the right note.
  • A cross-graph link (one that names an entity in another box) is emitted as a bare id with a (graph:: <box>) marker and flagged in the export manifest's warnings; on import the marker tells the importer not to resolve that target against local notes, so it can never silently re-point at a coincidentally same-id note in the import box.

This convention is exactly what the importer understands, so an exported vault round-trips losslessly.

Import a markdown vault

Use the graph tool's import action:

graph(action="import", graph="<target box>", source_dir="<vault dir>", format="obsidian")
  • Each .md becomes a note: frontmatter supplies type/tags/aliases/ status; the H1 (or frontmatter title, or filename) supplies the title; the remaining prose is the body.
  • **wikilinks** become links. A typed relation:: <RELATION> **target** line maps <RELATION> to a valid relation; an unknown relation is never dropped — it falls back to related with a warning. A bare wikilink defaults to related. Optional (confidence:: <float>) / (direction:: <dir>) / (graph:: <box>) subfields on a typed line restore a link's confidence/direction/cross-graph target. Prose is never silently dropped: a relation:: line that is not a valid typed wikilink (e.g. relation:: see chapter 3) is kept in the body verbatim, and a typed line that carries trailing prose (contradicts **Beta** but only in bear markets) still captures the contradicts edge (its relation is not downgraded to related) while keeping the whole line in the body, with a warning.
  • Two different files in one vault that claim the same frontmatter id are a conflict, not an idempotent re-import: the first is created and the second is skipped as duplicate-id with a warning (its distinct content is never silently discarded).
  • Imports are idempotent two ways:
  • By identity — a file whose frontmatter carries an id (a vault we exported) is deduped on that id: if a note with it already exists in the target box, the import is a no-op, so re-importing into the same box creates no duplicates. The id is untrusted input — it is validated (no path separators, .., or filename-unsafe characters) before use and is only ever used to mint a new note, never to overwrite an existing one.
  • By content — otherwise each note is stamped with its content_hash, so re-importing the same file is skipped.
  • Imported notes are marked epistemic_status = inferred — they carry no PDF/quote grounding, unlike notes extracted from a source.

Propose-only imports

When the server runs under ZK_PROPOSE_ONLY (or ZK_DISABLE_WRITE), import returns a dry-run manifest of the notes and links it would create and writes nothing to the canonical store — review it before running a real import.

Security

Vault import is the one untrusted-input surface here, so every byte is treated as data:

  • Paths are resolved to a realpath and confirmed to stay under source_dir; path traversal and symlink escape are rejected.
  • Per-file size and total file-count caps guard against oversized files and directory bombs.
  • Frontmatter is parsed with a safe YAML loader; file content is never executed, and no XML is parsed (so there is no external-entity/XXE surface).

Export the memory research tree

Export the persistent memory tree (entry nodes plus has_child / related edges) to GEXF with the relate tool:

relate(action="export")

It returns a gexf field with a GEXF 1.2 document (each live entry is a node labelled by its title, with type/status attributes) ready for Gephi or networkx. Discarded/tombstoned entries are filtered out (never emitted as live nodes), and the tree is auto-synced from disk before export.

Semantically related entries from the memory graph.