obsidian_canvas_read
Read an Obsidian JSON Canvas as a compact semantic graph. Returns node and edge counts, plus raw arrays. Optionally include full JSON.
Instructions
Read an Obsidian JSON Canvas as a compact semantic graph. Includes node/edge counts and raw arrays. Set includeRaw for full JSON.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| vault | No | Optional configured vault name. Defaults to the server default vault. | |
| path | Yes | Vault-relative path. Absolute paths and traversal are rejected. | |
| includeRaw | No |
Implementation Reference
- src/tools.ts:1088-1112 (registration)Registration of the 'obsidian_canvas_read' tool with Zod schema and handler function. The tool reads an Obsidian JSON Canvas, parses it, returns node/edge counts, arrays, semantic summary, and optionally the full raw JSON.
tool( "obsidian_canvas_read", "Read an Obsidian JSON Canvas as a compact semantic graph. Includes node/edge counts and raw arrays. Set includeRaw for full JSON.", { vault: vaultArg, path: pathArg, includeRaw: z.boolean().optional().default(false), }, async (args) => { const canvasPath = args.path.endsWith(".canvas") ? args.path : `${args.path}.canvas`; const read = await vaults.readText(canvasPath, args.vault); const raw = JSON.parse(read.text) as CanvasFile; const nodes = raw.nodes ?? []; const edges = raw.edges ?? []; return { path: read.path, summary: { nodeCount: nodes.length, edgeCount: edges.length }, nodes, edges, semantic: readCanvasSemantic(raw), raw: args.includeRaw ? raw : undefined, }; }, { readOnlyHint: true }, ); - src/tools.ts:1088-1112 (handler)The handler function for obsidian_canvas_read - reads a .canvas file from vault, parses JSON, extracts nodes/edges, calls readCanvasSemantic for semantic representation, and optionally includes raw data.
tool( "obsidian_canvas_read", "Read an Obsidian JSON Canvas as a compact semantic graph. Includes node/edge counts and raw arrays. Set includeRaw for full JSON.", { vault: vaultArg, path: pathArg, includeRaw: z.boolean().optional().default(false), }, async (args) => { const canvasPath = args.path.endsWith(".canvas") ? args.path : `${args.path}.canvas`; const read = await vaults.readText(canvasPath, args.vault); const raw = JSON.parse(read.text) as CanvasFile; const nodes = raw.nodes ?? []; const edges = raw.edges ?? []; return { path: read.path, summary: { nodeCount: nodes.length, edgeCount: edges.length }, nodes, edges, semantic: readCanvasSemantic(raw), raw: args.includeRaw ? raw : undefined, }; }, { readOnlyHint: true }, ); - src/tools.ts:1091-1095 (schema)Zod input schema for obsidian_canvas_read: vault (optional string), path (required string), includeRaw (optional boolean, default false).
{ vault: vaultArg, path: pathArg, includeRaw: z.boolean().optional().default(false), }, - src/canvas.ts:1-249 (helper)Canvas module providing CanvasFile type definitions and readCanvasSemantic function which converts raw canvas nodes/edges into a semantic representation with labels and connections.
import dagre from "@dagrejs/dagre"; export type CanvasNode = { id: string; type: "text" | "file" | "link" | "group"; x: number; y: number; width: number; height: number; text?: string; file?: string; url?: string; label?: string; color?: string; }; export type CanvasEdge = { id: string; fromNode: string; toNode: string; fromSide?: "top" | "right" | "bottom" | "left"; toSide?: "top" | "right" | "bottom" | "left"; label?: string; color?: string; }; export type CanvasFile = { nodes: CanvasNode[]; edges: CanvasEdge[]; }; export type CanvasNodeInput = { id?: string; label: string; type?: "text" | "file" | "link" | "group"; text?: string; file?: string; url?: string; color?: string; width?: number; height?: number; }; export type CanvasEdgeInput = { id?: string; from: string; to: string; label?: string; color?: string; }; export function createCanvas( nodes: CanvasNodeInput[], edges: CanvasEdgeInput[], options: { direction?: "TB" | "BT" | "LR" | "RL"; nodeWidth?: number; nodeHeight?: number } = {}, ): CanvasFile { const idMap = new Map<string, string>(); const canvasNodes: CanvasNode[] = nodes.map((node, index) => { const id = node.id ?? makeId(node.label, index); idMap.set(node.label, id); idMap.set(id, id); const type = node.type ?? (node.file ? "file" : node.url ? "link" : "text"); return { id, type, x: 0, y: 0, width: node.width ?? options.nodeWidth ?? 260, height: node.height ?? estimateHeight(node.text ?? node.label, options.nodeHeight ?? 90), label: node.label, text: type === "text" ? node.text ?? node.label : node.text, file: node.file, url: node.url, color: node.color, }; }); const canvasEdges: CanvasEdge[] = edges.map((edge, index) => ({ id: edge.id ?? `edge-${index + 1}`, fromNode: resolveId(idMap, edge.from), toNode: resolveId(idMap, edge.to), label: edge.label, color: edge.color, })); return relayoutCanvas({ nodes: canvasNodes, edges: canvasEdges }, options); } export function readCanvasSemantic(canvas: CanvasFile): { nodes: Array<{ id: string; label: string; type: string; file?: string; url?: string; text?: string; connections: string[] }>; edges: Array<{ from: string; to: string; label?: string }>; } { const labels = labelMap(canvas.nodes); return { nodes: canvas.nodes.map((node) => ({ id: node.id, label: nodeLabel(node), type: node.type, file: node.file, url: node.url, text: node.text, connections: canvas.edges .filter((edge) => edge.fromNode === node.id || edge.toNode === node.id) .map((edge) => labels.get(edge.fromNode === node.id ? edge.toNode : edge.fromNode) ?? "unknown"), })), edges: canvas.edges.map((edge) => ({ from: labels.get(edge.fromNode) ?? edge.fromNode, to: labels.get(edge.toNode) ?? edge.toNode, label: edge.label, })), }; } export function patchCanvas( canvas: CanvasFile, patch: { addNodes?: CanvasNodeInput[]; addEdges?: CanvasEdgeInput[]; updateNodes?: Array<{ match: string; label?: string; text?: string; file?: string; url?: string; color?: string }>; removeNodes?: string[]; removeEdges?: string[]; relayout?: boolean; direction?: "TB" | "BT" | "LR" | "RL"; }, ): { canvas: CanvasFile; changes: string[] } { const changes: string[] = []; const next: CanvasFile = { nodes: canvas.nodes.map((node) => ({ ...node })), edges: canvas.edges.map((edge) => ({ ...edge })), }; const labels = labelMap(next.nodes); const removeNodeIds = new Set((patch.removeNodes ?? []).map((match) => findNode(next.nodes, match)?.id).filter((id): id is string => Boolean(id))); if (removeNodeIds.size > 0) { next.nodes = next.nodes.filter((node) => !removeNodeIds.has(node.id)); next.edges = next.edges.filter((edge) => !removeNodeIds.has(edge.fromNode) && !removeNodeIds.has(edge.toNode)); changes.push(`removed ${removeNodeIds.size} nodes`); } for (const update of patch.updateNodes ?? []) { const node = findNode(next.nodes, update.match); if (!node) continue; if (update.label !== undefined) node.label = update.label; if (update.text !== undefined) node.text = update.text; if (update.file !== undefined) node.file = update.file; if (update.url !== undefined) node.url = update.url; if (update.color !== undefined) node.color = update.color; changes.push(`updated ${node.id}`); } const idMap = new Map<string, string>(next.nodes.flatMap((node) => [[node.id, node.id], [nodeLabel(node), node.id]])); for (const node of patch.addNodes ?? []) { const id = node.id ?? makeId(node.label, next.nodes.length); idMap.set(node.label, id); next.nodes.push({ id, type: node.type ?? (node.file ? "file" : node.url ? "link" : "text"), x: 0, y: 0, width: node.width ?? 260, height: node.height ?? estimateHeight(node.text ?? node.label, 90), label: node.label, text: node.text ?? node.label, file: node.file, url: node.url, color: node.color, }); changes.push(`added ${id}`); } const removeEdgeIds = new Set(patch.removeEdges ?? []); next.edges = next.edges.filter((edge) => !removeEdgeIds.has(edge.id)); if (removeEdgeIds.size > 0) changes.push(`removed ${removeEdgeIds.size} edges`); for (const edge of patch.addEdges ?? []) { next.edges.push({ id: edge.id ?? `edge-${next.edges.length + 1}`, fromNode: resolveId(idMap, edge.from), toNode: resolveId(idMap, edge.to), label: edge.label, color: edge.color, }); changes.push(`added edge ${edge.from} -> ${edge.to}`); } return { canvas: patch.relayout === false ? next : relayoutCanvas(next, { direction: patch.direction ?? "TB" }), changes, }; } export function relayoutCanvas( canvas: CanvasFile, options: { direction?: "TB" | "BT" | "LR" | "RL" } = {}, ): CanvasFile { const graph = new dagre.graphlib.Graph(); graph.setGraph({ rankdir: options.direction ?? "TB", nodesep: 70, ranksep: 100, marginx: 20, marginy: 20 }); graph.setDefaultEdgeLabel(() => ({})); for (const node of canvas.nodes) graph.setNode(node.id, { width: node.width, height: node.height }); for (const edge of canvas.edges) graph.setEdge(edge.fromNode, edge.toNode); dagre.layout(graph); const nodes = canvas.nodes.map((node) => { const positioned = graph.node(node.id); return positioned ? { ...node, x: Math.round(positioned.x - node.width / 2), y: Math.round(positioned.y - node.height / 2) } : node; }); const nodeById = new Map(nodes.map((node) => [node.id, node])); const edges = canvas.edges.map((edge) => { const from = nodeById.get(edge.fromNode); const to = nodeById.get(edge.toNode); return from && to ? { ...edge, ...edgeSides(from, to) } : edge; }); return { nodes, edges }; } function resolveId(idMap: Map<string, string>, value: string): string { const direct = idMap.get(value); if (direct) return direct; const lower = value.toLowerCase(); const found = [...idMap.entries()].find(([key]) => key.toLowerCase() === lower)?.[1]; if (!found) throw new Error(`Canvas node not found: ${value}`); return found; } function findNode(nodes: CanvasNode[], match: string): CanvasNode | undefined { const lower = match.toLowerCase(); return nodes.find((node) => node.id === match || nodeLabel(node).toLowerCase() === lower) ?? nodes.find((node) => nodeLabel(node).toLowerCase().includes(lower)); } function labelMap(nodes: CanvasNode[]): Map<string, string> { return new Map(nodes.map((node) => [node.id, nodeLabel(node)])); } function nodeLabel(node: CanvasNode): string { return node.label || node.file || node.url || node.text?.split("\n")[0] || node.id; } function makeId(label: string, index: number): string { const slug = label.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "node"; return `${slug}-${index + 1}`; } function estimateHeight(text: string, fallback: number): number { return Math.max(fallback, 60 + Math.ceil(text.length / 42) * 20); } function edgeSides(from: CanvasNode, to: CanvasNode): Pick<CanvasEdge, "fromSide" | "toSide"> { const dx = to.x - from.x; const dy = to.y - from.y; if (Math.abs(dx) >= Math.abs(dy)) { return dx >= 0 ? { fromSide: "right", toSide: "left" } : { fromSide: "left", toSide: "right" }; } return dy >= 0 ? { fromSide: "bottom", toSide: "top" } : { fromSide: "top", toSide: "bottom" }; } - src/canvas.ts:87-110 (helper)The readCanvasSemantic function called by the handler - transforms raw CanvasFile into semantic format with node labels, connections, and edge summaries.
export function readCanvasSemantic(canvas: CanvasFile): { nodes: Array<{ id: string; label: string; type: string; file?: string; url?: string; text?: string; connections: string[] }>; edges: Array<{ from: string; to: string; label?: string }>; } { const labels = labelMap(canvas.nodes); return { nodes: canvas.nodes.map((node) => ({ id: node.id, label: nodeLabel(node), type: node.type, file: node.file, url: node.url, text: node.text, connections: canvas.edges .filter((edge) => edge.fromNode === node.id || edge.toNode === node.id) .map((edge) => labels.get(edge.fromNode === node.id ? edge.toNode : edge.fromNode) ?? "unknown"), })), edges: canvas.edges.map((edge) => ({ from: labels.get(edge.fromNode) ?? edge.fromNode, to: labels.get(edge.toNode) ?? edge.toNode, label: edge.label, })), }; }