title: "@insurgently/topic-map-api"
built: 2026-10-30
source: api/README.md
html: https://insurgently.com/projects/topic-map/technical/agents/READMEIndex of these files: llms.txt
Types and a small helper for calling Topic Map from another Obsidian plugin.
Why this exists
Topic Map reads a person's books, notes and Bases into passages and cards, finds the topics they share, and answers questions with cited cards. Another plugin can use what it has indexed: search a collection, read a passage, rank a book against groups. It doesn't need its own index, and it never stores vectors.
Your plugin reaches Topic Map through app.plugins at runtime. This package gives you the types for that object, and the few lines that find it, check its version and wait for it to load. It has no dependencies, and it doesn't include Topic Map: if the person hasn't installed it, getTopicMap says so in a message you can show.
Install
npm install @insurgently/topic-map-api
It is ESM with its own types, and bundles into your main.js like any other code.
Quick look
import { getTopicMap, hasMethod, onTopicMapReady } from '@insurgently/topic-map-api';
// When the person asks a question:
const found = getTopicMap(this.app, '0.3.0', { by: 'My Plugin', for: 'to search your notes' });
if (!found.topicMap) return new Notice(found.message);
const { results } = await found.topicMap.search('Garden notes', 'when do I prune the apple trees?', { k: 8 });
// A call newer than your gate, or not offered yet:
if (hasMethod(found.topicMap, 'rankGroups')) {
// …
}
Open the map, and color it
openMap and registerMapLayer are newer than 0.3, so check each with hasMethod:
const tm = found.topicMap;
if (hasMethod(tm, 'openMap')) {
// A collection's map, with one card selected. In the library map a book's card id is `calibre:<id>`.
await tm.openMap({ collection: 'Garden notes', focus: 'c1' });
}
if (hasMethod(tm, 'registerMapLayer')) {
// A read-only layer: one mark (or null) per point, asked for in batches. Register again on `topic-map:ready`.
const layer = tm.registerMapLayer({
id: 'my-plugin-read',
label: 'Read',
levels: ['library'],
legend: [{ label: 'Finished', color: '#3b8f5f' }],
marks: (points) => points.map((p) => (isFinished(p.book) ? { fill: '#3b8f5f' } : { dim: true })),
});
// layer.refresh() when your data changes; layer.dispose() when your plugin unloads.
}
A layer never changes a collection, a shelf or a note. A batch that takes more than 10 seconds gets no marks, and the map says so. Each point carries the collection whose map it is on, so card ids from two collections can be told apart. The map reads your legend when you register and again on each refresh(): change it, then call refresh(). A legend the map can't draw is refused there, and the map keeps the last one. The refusal is thrown from refresh(), so call it inside try/catch (a refused legend also skips that refresh's marks).
openMap rejects with a TopicMapError you can branch on: invalid-request (an unknown collection, a focus that isn't on the map, the library map without Library Hub), not-indexed, collection-missing, hub-missing, and canceled when the layout was stopped or another map was opened in the map view first. A map that fails for another reason rejects with a plain Error naming why.
The catalog calls (API 0.5, not yet in a release) read and change a catalog's names and shape. A catalog's tree file names every node by a stable id, and that name is the one Topic Map shows everywhere:
if (hasMethod(tm, 'renameCatalogNode')) {
const names = await tm.catalogNames('magic'); // { 'mg.f1': 'Shape-changing', 'mg.t1.1.1': 'Swan maidens', … }
await tm.renameCatalogNode('magic', 'mg.t1.1.1', 'Bird-skin brides');
}
plugin.registerEvent(app.workspace.on('topic-map:catalog-renamed', ({ domain, id, name }) => refresh(domain, id, name)));
A rename writes the tree file (pinning the name, so a later redraft keeps it), the catalog note's cards, the node's heading in its family note and, for a family, the note's file name (links follow). It is logged as a run, so undoRun takes it back, and undoing it fires topic-map:catalog-renamed again with the name restored. In Topic Map, Rename on a card shows what it would change before writing; the API call is one-shot (it plans and writes in one go), since a program asking for a rename has no dialog to confirm. It refuses with catalog-missing (no catalog for that domain), catalog-node-missing (no node by that id) or invalid-request (an empty or overlong name).
moveCatalogNode(domain, id, newParentId) moves a type under another sub-family (of any family), or a sub-family under another family. The node keeps its id, and takes its versions and variants (a sub-family its types) with it:
if (hasMethod(tm, 'moveCatalogNode')) await tm.moveCatalogNode('magic', 'mg.t1.1.1', 'mg.s2.1');
plugin.registerEvent(app.workspace.on('topic-map:catalog-moved', ({ domain, id, from, to }) => refresh(domain, id, from, to)));
A move writes the tree file (the node goes to the end of its new parent's list), moves the node's sections from one family note to the other, and rewrites the catalog note's cards with fresh counts for the old and new parents. A parent left empty stays. Like a rename, it is one run that undoRun takes back, and undoing it fires topic-map:catalog-moved again with from and to swapped. It refuses with catalog-missing, catalog-node-missing (no node, or no new parent, by that id) or invalid-request (a family or variant as the node, a new parent of the wrong kind, or the parent it already has).
When a re-index changes a catalog's clusters, its tree is redrafted from itself (types whose cards still match keep their ids, names and places; new clusters are placed as new types). What the redraft couldn't keep stays in the tree as it was, and catalogRedraftReport(domain) lists it, with why:
const report = hasMethod(tm, 'catalogRedraftReport') ? await tm.catalogRedraftReport('magic') : null;
for (const { id, name, reason } of report?.items ?? []) console.log(`${id} ${name}: ${reason}`); // report.at: when it ran
It answers null when no redraft of that catalog is on record, and passes over one that was undone.
How it behaves
- The version is a gate.
getTopicMap(app, wanted)gives you the API only when it serves the version you built for. Before 1.0, that means the same minor or a later one, as far back as 0.1. Otherwise you get{ topicMap: null, reason, message }, withreasonmissingorincompatible. - Newer calls are checked one by one. The group calls came in API 0.3 the map calls (
openMap,registerMapLayer) in 0.4, and the catalog calls in 0.5, so a Topic Map older than your types may not have them.hasMethod(api, 'rankGroups')says whether one is there. - Topic Map can reload.
onTopicMapReadycalls you back now, if Topic Map is loaded, and again each time it loads (topic-map:ready). Drop your reference ontopic-map:unload. - Errors carry a code. Every refusal rejects with a
TopicMapErrorwhosecodeis always set and whose message is written for the person. Branch on the code, not the message.
For coding agents
- Find Topic Map with
getTopicMaporonTopicMapReady. Never useapp.plugins.getPlugin('topic-map')directly. - Gate on the lowest version that has every call you make unconditionally.
- Check anything newer than that gate with
hasMethodbefore calling it. - Treat an error's
codeas the contract. The message can change. - Never import from Topic Map's source. Everything public is in this package.
The full declarations for the installed version are in node_modules/@insurgently/topic-map-api/dist/index.d.ts, each with its doc comment.
Versions
The package version is the API version, and it is published after the Topic Map release that serves it. Before 1.0, a minor version may break the API; breaking changes are marked in the changelog.
License
MIT, Insurgently.