SLOPSHOPPER

graph-note

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

newpaneguardcommandtool
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · graph-note
│ ┃ Graph editor ✕ › fix the failing auth test and add an audit log call │ ┃ Open this address in the browser pane to │ ┃ edit the note: ⏺ Read(src/auth.ts) │ ┃ Open the graph editor [fn] ⎿ Read 6 lines │ ┃ [ Copy the address ] ⏺ Update(src/auth.ts) │ ┃ It holds a secret for this session only. It ⎿ Added 2 lines, removed 1 line │ ┃ stops when the session ends, or /graph stop. ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /graph │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Graph editor
Open this address in the browser pane to edit the note: Open the graph editor [fn] [ Copy the address ] It holds a secret for this session only. It stops when the session ends, or /graph stop.
Pane · graph
Name a note to draw: /graph <path to a note>, quoted if it has spaces.
README

Graph Note

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.

What is here

  • 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.
  • Each release also carries both libraries as one archive, 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.

Privacy and network use

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:

  • You choose what is sent: the whole graph, one of its views, or the things you tick. It is written out as GQL: each thing with its kinds and properties, and the links between them, with your question and the instructions that ask for changes. Nothing else of the vault goes: not the note's other text, not another graph, not a comment.
  • You see exactly what will be sent, and to which model and address, before it goes, and nothing goes until you press Send. Nothing is ever sent in the background.
  • Each model has a ceiling, the highest classification of note it may be sent, set by you. A note above it is not sent to that model, and a note at the highest classification you have set is never sent to any model. A note with no classification counts as the most sensitive level, so by default it is sent to no model: give it a classification, or choose a lower level for such notes under Unlabelled notes count as, in the plugin's settings.
  • Credentials are looked for by common patterns, such as a password or secret property, an API key, an access key, a token, a private key, a card number, or the key of any model you set up. What matches is never sent. These are patterns, not a guarantee: keep secrets out of notes you send.
  • A key stays in Obsidian's own secret store, never in the vault, the plugin's settings, a note or a log. A model on another machine is reached over https only, and a model's address may hold no user name, password or query string.
  • The answer comes back as a proposal, written into the note as a change set beside the graph, and each change is accepted or rejected by you. An AI never writes to the graph.
  • Every send is recorded in the plugin's own data, without the text sent: a SHA-256 digest of it, its size, what kind of choice it was and how many things and links, the model's name and address, the time, and what came of it. The last 200 sends are kept; the command "Clear the record of sends to AI models" clears them.

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.

Developing

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.

Testing in Obsidian, on desktop and phone

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.

Supply chain

  • Exact pins. Every dependency is pinned to an exact version, the lockfile is committed, and installs use npm ci.
  • No install scripts. .npmrc sets ignore-scripts=true.
  • Verified. CI runs npm audit signatures before every build.
  • Slow to adopt, quick to patch. Dependabot proposes version updates weekly, after a seven-day cooldown; security updates are raised at once.
  • Pinned actions. Every GitHub Action is pinned to a full commit hash, and CI runs with read-only permissions. Only the release job may write.
  • Built by CI. Releases are built from a version tag (no 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.
  • Fit to be public, checked every time. 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.
  • Dependencies reviewed, advisories re-read. Every pull request's dependency changes are read against the published advisories, and the Graphviz inside the engine is checked against them weekly.
  • No test framework. Tests use the runner built into Node.

Licence

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.

Source 2 files
hooks/register.tsx 266 lines
1// /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};
266
types/index.d.ts 14 lines
1// 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