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

An Obsidian plugin that draws the graphs written in notes, and lets them be edited beside the note — by typing, or by tapping and dragging the drawing. It works on desktop and mobile, offline.
Status: in development. Not yet in the community plugin directory. It is built in three slices: slices 1 and 2 are released, and slice 3 is in progress.
packages/obsidian — the plugin.packages/graph-spec — the graph format library in plain JavaScript: reader, rules, completeness, joins and views. One file with no dependencies, loadable by a web page or by Node.python/graph_spec — the same library in Python, plus the Graphviz drawing and the Grafeo-backed checks. Its answers are tested against the JavaScript library on every shared case.graph-spec-libraries-<version>.tar.gz. Every asset is listed with its SHA-256 in SHA256SUMS.manifest.json, versions.json, styles.css — the files the plugin store reads. main.js is built by CI from a tag and attached to each release; it is never committed.The plugin makes no network request of its own: no analytics, no update checks, no remote images, 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, and sends content only to AI models you have set up yourself, by their address, 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. Then, for each send:
Requests go straight from your device to the address you gave, with Obsidian's own request function: there is no server of this plugin's in between, and no AI vendor's library inside it. 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.
Requires Node 24 or later. How the code is written — the conventions, and how each is checked — is in CONTRIBUTING.md.
npm ci
git config core.hooksPath .githooks
npm test
npm run typecheck
npm run lint
npm run build
The second line installs the pre-commit check, which refuses any staged file that looks confidential or personal: a classification other than Public in a note's frontmatter, a note's uuid line, an absolute home-folder path, a personal email address, a phone number or a card number. CI runs the same check over every file, so a commit that skipped the hook is still caught.
These steps are all a fresh clone needs. CI runs them, exactly as written here, in a fresh clone on every pull request and before every release, so setting up never depends on anything from a maintainer's machine.
This is a one-person project and takes no outside contributions: pull requests and issues can be opened by collaborators only, and each pull request's conversation is locked when it opens.
The plugin is tested from installed files, not from the plugin store: npm run build (once) or npm run dev (on every save) writes main.js, and with manifest.json and styles.css at the repository root these are the three files a test vault installs into .obsidian/plugins/graph-note/: node scripts/reset-test-vault.mjs copies them in as real files, and Obsidian Sync carries them to a phone when it syncs installed community plugins. A new build reaches the vault only when that reset is run again. Reload the plugin in Obsidian after each build. Releases are still built only by CI, from a tag.
To look at the drawing and try its controls in an ordinary browser first, node packages/obsidian/harness/build.mjs <folder> 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 environment (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 graph_spec.CLASSIFICATION_NOTE, graph_spec.OWNER_ENGINE and graph_spec.WRITTEN_BY after importing it; the defaults name no machine or person, and no classification note is read until one is set.
npm ci..npmrc sets ignore-scripts=true.npm audit signatures before every build.v prefix), with SHA256SUMS for every asset, and a build provenance attestation once the repository is public: GitHub keeps attestations only for public repositories owned by a person.scripts/public-readiness.mjs runs in CI and before every release: no secret and nothing confidential or personal in any commit of the checked-out history (every branch and tag, before the repository is made public), every commit from a no-reply address, and a fresh clone set up from the steps above alone. The repository's settings on GitHub are held to the same script (--settings): collaborators only may open pull requests (and issues, a setting with no API that is set by hand), every action pinned, a read-only token, a fork's workflows run only once approved, secret scanning with push protection, Dependabot alerts and fixes, and CodeQL.MIT — see LICENSE. 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 266 lines1// /graph: draws a graph note's first graph in a pane, with the Graph Note 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 DATA_FOLDER_REFUSAL,
17 EditorLauncher,
18 guardDataFolder,
19 confirmArgs,
20 editArgs,
21 layOut,
22 notePath,
23 PANE_PALETTE,
24 readLinkVia,
25 realPathVia,
26 sayEdit,
27 sayProposed,
28 sayStopped,
29 settingsFrom,
30 viewOf,
31} from './graph.js';
32
33const PANE = 'graph';
34const EDITOR_PANE = 'graph-editor';
35const USAGE = 'Name a note to draw: /graph <path to a note>, quoted if it has spaces.';
36const EDITOR_USAGE = 'No editor is running. /graph edit <path to a note> starts one.';
37const NO_PICTURES = 'This surface cannot draw pictures, so the graph is shown as text. The desktop app draws it.';
38const PROPOSE = 'mcp__graph-note__propose';
39const PROPOSE_TOOL =
40 'Propose changes to the graph of the note open in the Graph Note editor. Nothing is applied: the changes wait on a card ' +
41 'in the editor for the user to accept or reject. Each change is one of: {op: "add-thing", name, kind, properties?}, ' +
42 '{op: "add-link", from, type, to}, {op: "set", thing, key, value}, {op: "remove-thing", thing}, ' +
43 '{op: "remove-link", from, type, to}, each with an optional "reason". Names are one word. You cannot add a Person, ' +
44 'a Model or a Step. "read" lists what you read, if you say so.';
45const PROPOSE_SCHEMA = {
46 type: 'object',
47 properties: {
48 changes: { type: 'array', items: { type: 'object', properties: { op: { type: 'string' } }, required: ['op'] } },
49 read: { type: 'array', items: { type: 'string' } },
50 },
51 required: ['changes'],
52};
53const view = atom({ plugin: 'graph-note', key: 'view' } as const, null as GraphView | null);
54// counts changes to the editor pane so it is drawn again; it holds no address, token or note
55const editorChanges = atom({ plugin: 'graph-note', key: 'editor' } as const, 0);
56
57/** The session's editor: made when first asked for, and kept for the module's life (its server dies with it). */
58let launcher: EditorLauncher | null = null;
59
60/** The mod's data folder (settings and drafts): `CLAUDE_PLUGIN_DATA`, else the plugins data folder under the config. */
61async function dataFolderOf($: EngineInterface): Promise<{ data: string; home?: string }> {
62 const home = await $.env.get('HOME');
63 // an empty CLAUDE_PLUGIN_DATA is unset
64 const data = (await $.env.get('CLAUDE_PLUGIN_DATA')) || `${home ?? ''}/.claude/plugins/data/graph-note`;
65 return home ? { data, home } : { data };
66}
67
68/** The tools the data folder's guard judges; the user changes the settings there, never Claude. */
69const GUARDED_TOOLS = ['Write', 'Edit', 'NotebookEdit', 'Bash'] as const;
70
71/** The launcher over this host's calls; the server it runs is the mod's own bin/editor-server.mjs. */
72async function launcherOf($: EngineInterface): Promise<EditorLauncher> {
73 if (launcher) return launcher;
74 const { data } = await dataFolderOf($);
75 launcher = new EditorLauncher({
76 spawn: (request) => $.process.spawn(request),
77 // a matrix note is read only when its real path, links settled, is inside the vault: the mod's file calls do not
78 // settle links, so node is asked
79 realPath: realPathVia(
80 (request) => $.process.spawn(request),
81 ['node', `${$.plugin.root}/bin/real-path.mjs`],
82 (ms, fn) => $.clock.after(ms, fn),
83 ),
84 exists: (path) => $.fs.exists(path),
85 read: (path) => $.fs.read(path),
86 // the user's settings, saved as settings.json in the plugin data folder; none, or one that cannot be read, is the
87 // defaults, which are the strictest: Claude's ceiling the lowest, a note with no classification the highest
88 settings: async () => {
89 try {
90 return settingsFrom(JSON.parse(await $.fs.read(`${data}/settings.json`)));
91 } catch {
92 return settingsFrom(null);
93 }
94 },
95 after: (ms, fn) => $.clock.after(ms, fn),
96 cwd: await $.session.cwd(),
97 server: ['node', `${$.plugin.root}/bin/editor-server.mjs`],
98 drafts: data,
99 // Claude's proposals: a key made for each start, and the shipped sender (node), which is handed the request on its
100 // standard input so the key is in no command line (cc_claude_proposes)
101 key: () => {
102 const bytes = new Uint8Array(32);
103 globalThis.crypto.getRandomValues(bytes);
104 return btoa(String.fromCharCode(...bytes))
105 .replace(/\+/g, '-')
106 .replace(/\//g, '_')
107 .replace(/=+$/, '');
108 },
109 propose: async (request) => {
110 const ran = await $.process.run(['node', `${$.plugin.root}/bin/propose.mjs`], {
111 stdin: JSON.stringify(request),
112 timeoutMs: 30_000,
113 });
114 return JSON.parse(ran.stdout) as { status: number; text: string };
115 },
116 changed: () => void update($, editorChanges, (n) => n + 1).then(() => $.ui.invalidate('ui.render')),
117 });
118 return launcher;
119}
120
121/** The view of the note at `path`, or of why it could not be read. */
122async function viewAt($: EngineInterface, path: string): Promise<GraphView> {
123 let note: string;
124 try {
125 note = await $.fs.read(path);
126 } catch (err) {
127 const why = `Could not read ${path}: ${err instanceof Error ? err.message : String(err)}`;
128 return { title: path, alt: why, lines: [why] };
129 }
130 const run = (argv: readonly string[], stdin: string, timeoutMs: number) => $.process.run(argv, { stdin, timeoutMs });
131 // the drawing carries light and dark and follows the app's own appearance (PANE_PALETTE says why)
132 return viewOf(note, (dots) => layOut(dots, $.plugin.root, run), PANE_PALETTE);
133}
134
135/**
136 * Draws the note in the graph pane, as /graph does; the text it answers with. The pane is one Claude can read, so the
137 * note is held to the same gate as the editor's (cc_classified_asks, cc_credential_never): one that may not open is not
138 * drawn, and the answer says why, with no text of the note.
139 */
140async function drawNote($: EngineInterface, path: string): Promise<string> {
141 const gate = await (await launcherOf($)).gate(path, await $.session.cwd());
142 if ('refuse' in gate) return `${gate.refuse} Nothing was drawn.`;
143 if ('ask' in gate) return `${gate.ask} Nothing was drawn: /graph edit ${path} asks first.`;
144 const shown = await viewAt($, path);
145 await update($, view, () => shown);
146 await $.ui.open({ id: PANE, title: `Graph: ${shown.title}` });
147 return shown.why ? `${shown.alt} — shown as text. ${shown.why}` : shown.alt;
148}
149
150export const register: Register = (on) => {
151 // Claude's own writes to the data folder are refused (spec §26): it could loosen its own gate. A guard that fails
152 // refuses too. Reads are not hooked.
153 for (const tool of GUARDED_TOOLS) {
154 on('tool.call', { tool }, async ($, e, next) => {
155 const { data, home } = await dataFolderOf($);
156 const refusal = await guardDataFolder(tool, e as Record<string, unknown>, {
157 data,
158 ...(home ? { home } : {}),
159 cwd: await $.session.cwd(),
160 realPath: realPathVia(
161 (request) => $.process.spawn(request),
162 ['node', `${$.plugin.root}/bin/real-path.mjs`],
163 (ms, fn) => $.clock.after(ms, fn),
164 ),
165 readLink: readLinkVia(
166 (request) => $.process.spawn(request),
167 ['node', `${$.plugin.root}/bin/real-path.mjs`],
168 (ms, fn) => $.clock.after(ms, fn),
169 ),
170 });
171 return refusal === null ? next(e) : { deny: refusal };
172 }).catch(($, e, next) => (next.called ? next(e) : { deny: DATA_FOLDER_REFUSAL }));
173 }
174
175 on('session.start', async ($, e, next) => {
176 await $.command.register({ name: 'graph', description: "Draw a graph note's first graph in a pane" });
177 // the one tool Claude has here: to propose changes to the note open in the editor. It writes nothing itself.
178 await $.tool.register({
179 name: 'propose',
180 description: PROPOSE_TOOL,
181 inputSchema: PROPOSE_SCHEMA,
182 isDeferred: false,
183 });
184 return next(e);
185 });
186
187 // Claude proposes; the server makes the change set, and the user accepts or rejects it on the card (cc_claude_proposes)
188 on('tool.call', { tool: PROPOSE }, async ($, e) => {
189 const outcome = await (await launcherOf($)).propose({ changes: e.changes, read: e.read });
190 return outcome.ok ? { result: sayProposed(outcome) } : { deny: sayProposed(outcome) };
191 });
192
193 on('command.run', { command: 'graph' }, async ($, e) => {
194 if (e.args.trim() === 'stop') {
195 const stopped = launcher ? await launcher.stop('/graph stop') : false;
196 return { text: stopped ? sayStopped('/graph stop') : EDITOR_USAGE };
197 }
198 const confirming = confirmArgs(e.args);
199 const editing = editArgs(e.args);
200 if (confirming || editing !== null) {
201 // who ran the command, as the host says: only the composer is the person typing, and nothing else may say yes
202 const origin = (e as { origin?: unknown }).origin;
203 const opened = confirming
204 ? await (await launcherOf($)).confirm(origin)
205 : await (await launcherOf($)).edit(editing ?? '', await $.session.cwd(), origin);
206 if (!opened.ok) {
207 // the editor did not start: say why, and show the drawing /graph gives, unless the gate held the note back
208 if (editing && !opened.held) await drawNote($, editing);
209 return { text: sayEdit(opened) };
210 }
211 await update($, editorChanges, (n) => n + 1);
212 await $.ui.open({ id: EDITOR_PANE, title: 'Graph editor', focus: true });
213 return { text: sayEdit(opened) };
214 }
215 const path = notePath(e.args);
216 if (!path) return { text: USAGE };
217 return { text: await drawNote($, path) };
218 });
219
220 // the session ending ends its server
221 on('session.end', async ($, e, next) => {
222 await launcher?.stop('the session ended');
223 return next(e);
224 });
225
226 // the one place the address is read: to draw it for the person, and to copy it for them
227 on('ui.render', { component: 'Pane', requestId: EDITOR_PANE }, async ($, e) => {
228 const { Box, Text, Link, Button } = $.ui.resolve(e);
229 await read($, editorChanges);
230 const href = launcher?.link ?? null;
231 if (href === null) {
232 const ended = launcher?.ended;
233 return <Text dimColor>{ended ? sayStopped(ended) : EDITOR_USAGE}</Text>;
234 }
235 return (
236 <Box flexDirection="column">
237 <Text>Open this address in the browser pane to edit the note:</Text>
238 <Link href={href} label="Open the graph editor" />
239 <Button label="Copy the address" onPress={(press) => $.ui.copy({ text: href, surface: press.surface })} />
240 <Text dimColor>It holds a secret for this session only. It stops when the session ends, or /graph stop.</Text>
241 </Box>
242 );
243 });
244
245 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
246 const elements = $.ui.resolve(e);
247 const { Box, Text } = elements;
248 const shown = await read($, view);
249 if (!shown) return <Text dimColor>{USAGE}</Text>;
250
251 // the terminal draws no Svg (Image and Raster only), though `'Svg' in` its table holds: the text stands in there
252 const Svg = e.surface !== 'terminal' && 'Svg' in elements ? elements.Svg : undefined;
253 if (Svg && shown.svg) return <Svg source={shown.svg} alt={shown.alt} />;
254 // never text with no reason: a graph that could be drawn says why it is text here
255 const why = shown.why ?? (shown.svg ? NO_PICTURES : undefined);
256 return (
257 <Box flexDirection="column">
258 {why ? <Text color="warning">⚠ {why}</Text> : null}
259 {shown.lines.map((line, i) => (
260 <Text key={`line-${i}`}>{line}</Text>
261 ))}
262 </Box>
263 );
264 });
265};
266types/index.d.ts 14 lines1// The mod's state: the view the pane draws, kept by /graph. Self-contained, as a contract must be; register.tsx stores
2// 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 'graph-note': { view: GraphView | null; editor: number };
12 }
13}
14