/graph draws a graph note's first graph in a pane, with the ThoughtKnot core

ThoughtKnot turns what you know into a map you can see, change and share. You write things and the links between them as plain text, and it draws them. You can edit a graph by typing or by dragging the drawing.
It is one product with several ways in. The Obsidian plugin works now, on desktop and phone, offline. A standalone app, a VS Code plugin and a JavaScript library come next.
<!-- readme:status --> Status: in development. It is not in the community plugin directory yet. It is built in ten slices. Slices 1 to 4 are done, and slice 5 is in progress. <!-- /readme:status -->
| You want to | Go to |
|---|---|
| Use it | How to use ThoughtKnot |
| Know what it sends, and when | Privacy and network use |
| Build it or change it | Developing and CONTRIBUTING.md |
| See how it fits together | How the packages fit |
packages/obsidian: the plugin.packages/graph-spec: the graph format library in plain JavaScript. It reads graphs, checks their rules and answers questions about them. It is one file with no dependencies, so a web page or Node can load it.python/graph_spec: the same library in Python. It also draws with Graphviz and runs extra checks with Grafeo. Its answers are tested against the JavaScript library on every shared case.graph-spec-libraries-<version>.tar.gz. SHA256SUMS lists every file in the release with its SHA-256.manifest.json, versions.json and styles.css: the files the plugin store reads. CI builds main.js from a tag and attaches it to each release. It is never committed.Each layer uses only the one below it. So a new host, such as a VS Code extension or a desktop app, reuses everything below the hosts as it is.
hosts packages/obsidian packages/claude-code <- packages/app, packages/vscode
\ /
core packages/core host-free: reading, drawing, edits, undo
|
format packages/graph-spec <=> python/graph_spec
(JavaScript) held to the same answers (Python)
packages/graph-spec: the graph format. It reads GQL blocks and runs the spec's rules, with no dependencies. Guide: packages/graph-spec/GUIDE.md.python/graph_spec: the same format in Python, function for function. It adds the Graphviz drawing and the Grafeo checks. Shared tests hold the two to the same answers. Guide: python/graph_spec/GUIDE.md.packages/core: the editor core, on top of graph-spec. It works out what to draw, the problems and their fixes, zoom, edits and undo. It uses no Obsidian, editor or Node module, so it runs on a phone too. Guide: packages/core/GUIDE.md.packages/obsidian: the Obsidian plugin. It puts the core's drawings and edits on Obsidian's page. Guide: packages/obsidian/GUIDE.md.packages/claude-code: the Claude Code host. Its /graph <note> command draws a note's graph in a pane. It also serves a browser editor on your own machine, on the same core. Guide: packages/claude-code/GUIDE.md.packages/app: the desktop app, for macOS, Windows and Linux. It runs the browser editor in a Tauri window, over a plain folder of notes, offline, with no server inside. Guide: packages/app/GUIDE.md.packages/vscode: the VS Code extension. It runs the browser editor in a VS Code tab beside a Markdown note, over the note's workspace folder. Guide: packages/vscode/GUIDE.md.The short version for every app is the privacy page. It says what is kept, where, for how long, and how to delete it.
The short version, for the plugin, the app and the browser editor, is the privacy page.
The plugin makes no network request of its own. There are no analytics, no update checks, no remote images and no databases. Graphviz, the one runtime dependency, is bundled and runs on your device.
The one exception: AI models you set up. The plugin can ask an AI model to propose changes to a graph. It sends content only to AI models you have set up yourself, and only when you send it. AI is off until you turn it on (Use AI models, in the plugin's settings) and set up a model.
Where your text ends up. A model at a remote address receives the text you are shown. It keeps it under its provider’s own terms and retention, so it leaves your control once sent. A model at localhost runs on your device, so the text stays on your device.
Requests go straight from your device to the address you gave, through Obsidian's own request function. There is no server of ours in between, and no AI vendor's library inside the plugin.
Obsidian follows redirects; point a model only at an address you trust. Obsidian also adds headers of its own to each request, beyond those shown before you send.
You need Node 24 or later. CONTRIBUTING.md says how the code is written and how each rule is checked.
npm ci
git config core.hooksPath .githooks
npm test
npm run typecheck
npm run lint
npm run build
The second line turns on the pre-commit check. It refuses any staged file that looks confidential or personal:
CI runs the same check over every file, so a commit that skipped the hook is still caught.
A fresh clone needs only these steps. CI runs them exactly as written here, in a fresh clone:
So setting up never depends on a maintainer's machine.
Only collaborators can open pull requests and issues for now, and each pull request's conversation is locked when it opens. Ideas and questions are welcome in GitHub Discussions. CONTRIBUTING.md says how to ask to join.
The plugin is tested from installed files, not from the plugin store.
npm run build writes main.js once. npm run dev writes it on every save.node scripts/reset-test-vault.mjs copies main.js, manifest.json and styles.css into the test vault's .obsidian/plugins/thoughtknot/, as real files.Obsidian Sync carries the files to a phone when it syncs installed community plugins. A new build reaches the vault only when you run the reset again. Releases are still built only by CI, from a tag.
To try the drawing in an ordinary browser first, run node packages/obsidian/harness/build.mjs <folder>. It builds a page that draws a sample note with the real plugin and a small stand-in for Obsidian. Serve the folder and open it.
The Python library has its own pinned setup (uv, Python 3.14):
cd python/graph_spec
uv sync --locked
uv run --locked python -m unittest discover -s tests
A host that uses the Python library sets three values after importing it: graph_spec.CLASSIFICATION_NOTE, graph_spec.OWNER_ENGINE and graph_spec.WRITTEN_BY. The defaults name no machine or person. No classification note is read until one is set.
npm ci..npmrc sets ignore-scripts=true.npm audit signatures before every build.v in front. Each has SHA256SUMS for every file. Once the repository is public, each also gets a build provenance attestation. GitHub keeps those only for public repositories owned by a person.scripts/public-readiness.mjs runs in CI and before every release. It checks every branch and tag for secrets and for anything confidential or personal. It checks that every commit comes from a no-reply address. It also sets up a fresh clone from the steps above alone.--settings) checks the repository's settings. Collaborators only may open pull requests and issues. Every action is pinned and the token is read-only. A fork's workflows run only once approved. Secret scanning with push protection, Dependabot alerts and fixes, and CodeQL are on.ThoughtKnot uses semantic versions, with one catch before 1.0.0. Then a step in the leftmost part that is not 0 may change or remove what the library exports. So 0.7 to 0.8 may break your code. A patch step, such as 0.8.0 to 0.8.1, never does. Each release's notes have a "Library changes" section. It lists what was added, changed or removed.
Mozilla Public License 2.0 (MPL-2.0). See LICENSE.
The graph spec's own text is under CC-BY-4.0, so anyone may build on the format if they credit it. See LICENSE-SPEC.md for the files it covers.
The layout engine, @viz-js/viz, compiles Graphviz (Eclipse Public License 2.0) into WebAssembly. See THIRD_PARTY_NOTICES.md, which ships with every release.
hooks/register.tsx 281 lines1// /graph: draws a graph note's first graph in a pane, with the ThoughtKnot core (spec §10, a Claude Code host). Reading,
2// the layout source and theming run here, in the mod's own environment (graph.js); Graphviz runs as a process through
3// node (bin/render.mjs), since a mod has no WebAssembly. Where no drawing can be, the pane shows the graph as text with
4// the reason: when node is missing, on the terminal (which draws no Svg), and for a graph too large to draw.
5//
6// /graph edit <note> starts the session's browser editor (spec §26, bin/editor-server.mjs) and draws, in a pane of its
7// own, the address to open in the browser pane. The address carries the session's secret token, so it is drawn and
8// copied and goes nowhere else: not into the command's answer, a log line, a toast, state or the conversation
9// (cc_transcript_clean). /graph stop stops the server; so does the session ending. When the server cannot start, the
10// answer says why and the pane shows the drawing /graph gives.
11import { atom, read, update } from 'claude-code';
12import type { EngineInterface, Register } from 'claude-code';
13
14import type { GraphView } from '../types';
15import {
16 dataFolderRefusal,
17 EditorLauncher,
18 guardDataFolder,
19 guardGateFiles,
20 guardOpenNote,
21 pathOf,
22 confirmArgs,
23 editArgs,
24 followSystemLanguage,
25 layOut,
26 notePath,
27 PANE_PALETTE,
28 readLinkVia,
29 realPathVia,
30 sayEdit,
31 sayProposed,
32 sayStopped,
33 settingsFrom,
34 viewOf,
35 word,
36} from './graph.js';
37
38const PANE = 'graph';
39const EDITOR_PANE = 'graph-editor';
40const PROPOSE = 'mcp__thoughtknot__propose';
41const PROPOSE_TOOL =
42 'Propose changes to the graph of the note open in the ThoughtKnot editor. Nothing is applied: the changes wait on a card ' +
43 'in the editor for the user to accept or reject. Each change is one of: {op: "add-thing", name, kind, properties?}, ' +
44 '{op: "add-link", from, type, to}, {op: "set", thing, key, value}, {op: "remove-thing", thing}, ' +
45 '{op: "remove-link", from, type, to}, each with an optional "reason". Names are one word. You cannot add a Person, ' +
46 'a Model or a Step. "read" lists what you read, if you say so.';
47const PROPOSE_SCHEMA = {
48 type: 'object',
49 properties: {
50 changes: { type: 'array', items: { type: 'object', properties: { op: { type: 'string' } }, required: ['op'] } },
51 read: { type: 'array', items: { type: 'string' } },
52 },
53 required: ['changes'],
54};
55const view = atom({ plugin: 'thoughtknot', key: 'view' } as const, null as GraphView | null);
56// counts changes to the editor pane so it is drawn again; it holds no address, token or note
57const editorChanges = atom({ plugin: 'thoughtknot', key: 'editor' } as const, 0);
58
59/** The session's editor: made when first asked for, and kept for the module's life (its server dies with it). */
60let launcher: EditorLauncher | null = null;
61
62/** The mod's data folder (settings and drafts): `CLAUDE_PLUGIN_DATA`, else the plugins data folder under the config. */
63async function dataFolderOf($: EngineInterface): Promise<{ data: string; home?: string }> {
64 const home = await $.env.get('HOME');
65 // an empty CLAUDE_PLUGIN_DATA is unset
66 const data = (await $.env.get('CLAUDE_PLUGIN_DATA')) || `${home ?? ''}/.claude/plugins/data/thoughtknot`;
67 return home ? { data, home } : { data };
68}
69
70/** The user's settings, saved as settings.json in the data folder; none, or one that cannot be read, is the defaults. */
71async function settingsAt($: EngineInterface, data: string) {
72 try {
73 return settingsFrom(JSON.parse(await $.fs.read(`${data}/settings.json`)));
74 } catch {
75 return settingsFrom(null);
76 }
77}
78
79/** The launcher over this host's calls; the server it runs is the mod's own bin/editor-server.mjs. */
80async function launcherOf($: EngineInterface): Promise<EditorLauncher> {
81 if (launcher) return launcher;
82 const { data } = await dataFolderOf($);
83 launcher = new EditorLauncher({
84 spawn: (request) => $.process.spawn(request),
85 // a matrix note is read only when its real path, links settled, is inside the vault: the mod's file calls do not
86 // settle links, so node is asked
87 realPath: realPathVia(
88 (request) => $.process.spawn(request),
89 ['node', `${$.plugin.root}/bin/real-path.mjs`],
90 (ms, fn) => $.clock.after(ms, fn),
91 ),
92 exists: (path) => $.fs.exists(path),
93 read: (path) => $.fs.read(path),
94 // the user's settings, saved as settings.json in the plugin data folder; none, or one that cannot be read, is the
95 // defaults, which are the strictest: Claude's ceiling the lowest, a note with no classification the highest
96 settings: () => settingsAt($, data),
97 after: (ms, fn) => $.clock.after(ms, fn),
98 cwd: await $.session.cwd(),
99 server: ['node', `${$.plugin.root}/bin/editor-server.mjs`],
100 drafts: data,
101 // Claude's proposals: a key made for each start, and the shipped sender (node), which is handed the request on its
102 // standard input so the key is in no command line (cc_claude_proposes)
103 key: () => {
104 const bytes = new Uint8Array(32);
105 globalThis.crypto.getRandomValues(bytes);
106 return btoa(String.fromCharCode(...bytes))
107 .replace(/\+/g, '-')
108 .replace(/\//g, '_')
109 .replace(/=+$/, '');
110 },
111 propose: async (request) => {
112 const ran = await $.process.run(['node', `${$.plugin.root}/bin/propose.mjs`], {
113 stdin: JSON.stringify(request),
114 timeoutMs: 30_000,
115 });
116 return JSON.parse(ran.stdout) as { status: number; text: string };
117 },
118 changed: () => void update($, editorChanges, (n) => n + 1).then(() => $.ui.invalidate('ui.render')),
119 });
120 return launcher;
121}
122
123/** The view of the note at `path`, or of why it could not be read. */
124async function viewAt($: EngineInterface, path: string): Promise<GraphView> {
125 let note: string;
126 try {
127 note = await $.fs.read(path);
128 } catch (err) {
129 const why = word('cc.could-not-read', { path, why: err instanceof Error ? err.message : String(err) });
130 return { title: path, alt: why, lines: [why] };
131 }
132 const run = (argv: readonly string[], stdin: string, timeoutMs: number) => $.process.run(argv, { stdin, timeoutMs });
133 // the drawing carries light and dark and follows the app's own appearance (PANE_PALETTE says why)
134 return viewOf(note, (dots) => layOut(dots, $.plugin.root, run), PANE_PALETTE);
135}
136
137/**
138 * Draws the note in the graph pane, as /graph does; the text it answers with. The pane is one Claude can read, so the
139 * note is held to the same gate as the editor's (cc_classified_asks, cc_credential_never): one that may not open is not
140 * drawn, and the answer says why, with no text of the note.
141 */
142async function drawNote($: EngineInterface, path: string): Promise<string> {
143 const gate = await (await launcherOf($)).gate(path, await $.session.cwd());
144 if ('refuse' in gate) return word('cc.refused-not-drawn', { why: gate.refuse });
145 if ('ask' in gate) return word('cc.asks-not-drawn', { why: gate.ask, path });
146 const shown = await viewAt($, path);
147 await update($, view, () => shown);
148 await $.ui.open({ id: PANE, title: word('cc.pane-title', { title: shown.title }) });
149 return shown.why ? word('cc.shown-as-text', { alt: shown.alt, why: shown.why }) : shown.alt;
150}
151
152export const register: Register = (on) => {
153 // the pane's words are said in the system's language (spec §42)
154 followSystemLanguage();
155 // Claude's own writes to the data folder are refused (spec §26): it could loosen its own gate. So are its writes to
156 // the note open in the editor (cc_claude_proposes, risk AI-1): it changes that note only by the propose tool, which
157 // the refusal names. So are its writes to the matrix note and to a classification line (risk TRUST-1). Every tool is
158 // hooked, as the guards judge any tool whose input names a path, not only the ones listed (risk TRUST-2). A guard
159 // that fails refuses too. Reads are not judged.
160 on('tool.call', async ($, e, next) => {
161 const tool = e.tool;
162 const input = e as Record<string, unknown>;
163 if (tool !== 'Bash' && pathOf(tool, input) === null) return next(e);
164 const { data, home } = await dataFolderOf($);
165 const ports = {
166 ...(home ? { home } : {}),
167 cwd: await $.session.cwd(),
168 realPath: realPathVia(
169 (request) => $.process.spawn(request),
170 ['node', `${$.plugin.root}/bin/real-path.mjs`],
171 (ms, fn) => $.clock.after(ms, fn),
172 ),
173 readLink: readLinkVia(
174 (request) => $.process.spawn(request),
175 ['node', `${$.plugin.root}/bin/real-path.mjs`],
176 (ms, fn) => $.clock.after(ms, fn),
177 ),
178 };
179 const refusal =
180 (await guardDataFolder(tool, input, { data, ...ports })) ??
181 (await guardOpenNote(tool, input, { note: launcher?.note ?? null, ...ports })) ??
182 (await guardGateFiles(tool, input, {
183 matrixNote: (await settingsAt($, data)).aiMatrixNote ?? null,
184 read: (path) => $.fs.read(path),
185 ...ports,
186 }));
187 return refusal === null ? next(e) : { deny: refusal };
188 }).catch(($, e, next) => (next.called ? next(e) : { deny: dataFolderRefusal() }));
189
190 on('session.start', async ($, e, next) => {
191 await $.command.register({ name: 'graph', description: word('cc.command') });
192 // the one tool Claude has here: to propose changes to the note open in the editor. It writes nothing itself.
193 await $.tool.register({
194 name: 'propose',
195 description: PROPOSE_TOOL,
196 inputSchema: PROPOSE_SCHEMA,
197 isDeferred: false,
198 });
199 return next(e);
200 });
201
202 // Claude proposes; the server makes the change set, and the user accepts or rejects it on the card (cc_claude_proposes)
203 on('tool.call', { tool: PROPOSE }, async ($, e) => {
204 const outcome = await (await launcherOf($)).propose({ changes: e.changes, read: e.read });
205 return outcome.ok ? { result: sayProposed(outcome) } : { deny: sayProposed(outcome) };
206 });
207
208 on('command.run', { command: 'graph' }, async ($, e) => {
209 if (e.args.trim() === 'stop') {
210 const stopped = launcher ? await launcher.stop('/graph stop') : false;
211 return { text: stopped ? sayStopped('/graph stop') : word('cc.editor.usage') };
212 }
213 const confirming = confirmArgs(e.args);
214 const editing = editArgs(e.args);
215 if (confirming || editing !== null) {
216 // who ran the command, as the host says: only the composer is the person typing, and nothing else may say yes
217 const origin = (e as { origin?: unknown }).origin;
218 const opened = confirming
219 ? await (await launcherOf($)).confirm(origin)
220 : await (await launcherOf($)).edit(editing ?? '', await $.session.cwd(), origin);
221 if (!opened.ok) {
222 // the editor did not start: say why, and show the drawing /graph gives, unless the gate held the note back
223 if (editing && !opened.held) await drawNote($, editing);
224 return { text: sayEdit(opened) };
225 }
226 await update($, editorChanges, (n) => n + 1);
227 await $.ui.open({ id: EDITOR_PANE, title: word('cc.editor.pane-title'), focus: true });
228 return { text: sayEdit(opened) };
229 }
230 const path = notePath(e.args);
231 if (!path) return { text: word('cc.usage') };
232 return { text: await drawNote($, path) };
233 });
234
235 // the session ending ends its server
236 on('session.end', async ($, e, next) => {
237 await launcher?.stop(word('server.session-ended'));
238 return next(e);
239 });
240
241 // the one place the address is read: to draw it for the person, and to copy it for them
242 on('ui.render', { component: 'Pane', requestId: EDITOR_PANE }, async ($, e) => {
243 const { Box, Text, Link, Button } = $.ui.resolve(e);
244 await read($, editorChanges);
245 const href = launcher?.link ?? null;
246 if (href === null) {
247 const ended = launcher?.ended;
248 return <Text dimColor>{ended ? sayStopped(ended) : word('cc.editor.usage')}</Text>;
249 }
250 return (
251 <Box flexDirection="column">
252 <Text>{word('cc.editor.open-address')}</Text>
253 <Link href={href} label={word('cc.editor.open-link')} />
254 <Button label={word('cc.editor.copy')} onPress={(press) => $.ui.copy({ text: href, surface: press.surface })} />
255 <Text dimColor>{word('cc.editor.secret')}</Text>
256 </Box>
257 );
258 });
259
260 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
261 const elements = $.ui.resolve(e);
262 const { Box, Text } = elements;
263 const shown = await read($, view);
264 if (!shown) return <Text dimColor>{word('cc.usage')}</Text>;
265
266 // the terminal draws no Svg (Image and Raster only), though `'Svg' in` its table holds: the text stands in there
267 const Svg = e.surface !== 'terminal' && 'Svg' in elements ? elements.Svg : undefined;
268 if (Svg && shown.svg) return <Svg source={shown.svg} alt={shown.alt} />;
269 // never text with no reason: a graph that could be drawn says why it is text here
270 const why = shown.why ?? (shown.svg ? word('cc.no-pictures') : undefined);
271 return (
272 <Box flexDirection="column">
273 {why ? <Text color="warning">⚠ {why}</Text> : null}
274 {shown.lines.map((line, i) => (
275 <Text key={`line-${i}`}>{line}</Text>
276 ))}
277 </Box>
278 );
279 });
280};
281types/index.d.ts 14 lines1// The mod's state: the view the pane draws, kept by /graph. It is self-contained, as a contract must be. register.tsx
2// stores graph-view.ts's own GraphView in it, so a type check of the mod holds the two alike.
3
4/** One graph as the pane shows it: the SVG where it could be drawn, and always the same graph as lines of text. */
5export type GraphView = { title: string; svg?: string; alt: string; lines: string[]; why?: string };
6
7declare module 'claude-code' {
8 interface PluginState {
9 // `editor` counts the changes of the editor pane (a server started, ended or stopped), so it is drawn again. It
10 // holds no address, token or note: those never enter state, which the host may keep.
11 thoughtknot: { view: GraphView | null; editor: number };
12 }
13}
14