@frebreco/canvas 0.3.0-next.1 → 0.3.0-next.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frebreco/canvas",
3
- "version": "0.3.0-next.1",
3
+ "version": "0.3.0-next.2",
4
4
  "description": "A multiplayer canvas for coding agents that run on your machine.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -12,12 +12,18 @@
12
12
  * paths are checked against the shared set here first (ADR 0002), so the
13
13
  * agent hears why a file can't be shown. It listens on loopback only: agents
14
14
  * run on this machine.
15
+ *
16
+ * Scratch files (ADR 0005) never reach the browser as content: a call's
17
+ * `content` becomes a file in `.canvas/scratch/` here, and the call goes on
18
+ * with its path. Reading and writing them needs no board, so those tools are
19
+ * answered here.
15
20
  */
16
21
 
17
22
  import { randomBytes } from "node:crypto";
18
23
  import type { McpServer } from "@agentclientprotocol/sdk";
19
24
  import { BOARD_SERVER_NAME, BOARD_TOOLS, boardInstructions } from "../shared/board-tools";
20
25
  import type { FileContent } from "../shared/protocol";
26
+ import type { Scratch } from "./scratch";
21
27
 
22
28
  export interface BoardCall {
23
29
  readonly callId: string;
@@ -29,6 +35,7 @@ export interface BoardCall {
29
35
  export interface BoardMcpOptions {
30
36
  /** A file as the shared set lets it out. */
31
37
  readonly read: (path: string) => FileContent;
38
+ readonly scratch: Pick<Scratch, "create" | "write" | "read" | "list" | "remove">;
32
39
  /** Send a call to the host's browser; false if none is connected. */
33
40
  readonly relay: (call: BoardCall) => boolean;
34
41
  readonly timeoutMs?: number;
@@ -151,22 +158,84 @@ export class BoardMcp {
151
158
  private async call(
152
159
  sessionId: string,
153
160
  tool: string,
154
- args: Record<string, unknown>,
161
+ input: Record<string, unknown>,
155
162
  ): Promise<ToolResult> {
156
163
  if (!BOARD_TOOLS.some((t) => t.name === tool)) return failed(`no tool ${tool}`);
157
- let note = "";
158
- if (typeof args.path === "string" && args.path.trim()) {
159
- const path = args.path.trim().replace(/^(\.\/)+/, "");
160
- const file = this.options.read(path);
161
- if (file.kind === "denied") return failed(file.reason);
162
- if (file.kind === "missing")
163
- note = `\n(${path} doesn't exist yet; the frame shows it as soon as it is written.)`;
164
- if (file.kind === "text" && typeof args.start_line === "number") {
165
- const lines = file.text.split("\n").length - (file.text.endsWith("\n") ? 1 : 0);
166
- if (args.start_line > lines) return failed(`${path} has ${lines} lines`);
164
+ const args = { ...input };
165
+ const notes: string[] = [];
166
+ // Content becomes a scratch file; the board only ever sees its path.
167
+ const created: string[] = [];
168
+ const undo = () => created.forEach((path) => this.options.scratch.remove(path));
169
+ try {
170
+ switch (tool) {
171
+ case "read_board_file":
172
+ return done(this.readScratch(args.path));
173
+ case "write_board_file":
174
+ return done(this.writeScratch(args));
167
175
  }
176
+ if (args.content !== undefined) {
177
+ if (args.path !== undefined) throw new Error("give either path or content, not both");
178
+ args.path = this.createScratch(args.name, args.content, created, notes);
179
+ delete args.content;
180
+ delete args.name;
181
+ } else this.checkPath(args, notes);
182
+ if (Array.isArray(args.files))
183
+ args.files = args.files.map((value: unknown) => {
184
+ if (typeof value !== "object" || value === null)
185
+ throw new Error("each entry of files is an object with a display path");
186
+ const entry = { ...(value as Record<string, unknown>) };
187
+ if (entry.content !== undefined) {
188
+ if (entry.path !== undefined)
189
+ throw new Error("give a list entry either path or content, not both");
190
+ // No name: one from the display path's last segment, e.g. "1 Overview.md" → 1-Overview.md.
191
+ const name =
192
+ entry.name ??
193
+ (String(entry.display ?? "")
194
+ .split("/")
195
+ .at(-1)!
196
+ .replace(/[^\w.-]+/g, "-")
197
+ .replace(/^[^\w]+/, "") ||
198
+ "file");
199
+ entry.path = this.createScratch(name, entry.content, created, notes);
200
+ delete entry.content;
201
+ delete entry.name;
202
+ } else this.checkPath(entry, notes);
203
+ return entry;
204
+ });
205
+ } catch (error) {
206
+ undo();
207
+ return failed((error as Error).message);
208
+ }
209
+ if (tool === "view_board") {
210
+ const scratch = this.options.scratch.list();
211
+ notes.push(
212
+ scratch.length ? `Scratch files: ${scratch.join(", ")}.` : "No scratch files yet.",
213
+ );
214
+ }
215
+
216
+ const answer = await this.relay(sessionId, tool, args);
217
+ if (answer.isError) {
218
+ undo();
219
+ return answer;
220
+ }
221
+ return notes.length ? done([answer.content[0]!.text, ...notes].join("\n")) : answer;
222
+ }
223
+
224
+ /** A named file must be in the shared set (or a scratch file), and have the lines asked for. */
225
+ private checkPath(args: Record<string, unknown>, notes: string[]) {
226
+ if (typeof args.path !== "string" || !args.path.trim()) return;
227
+ const path = args.path.trim().replace(/^(\.\/)+/, "");
228
+ const file = this.options.read(path);
229
+ if (file.kind === "denied") throw new Error(file.reason);
230
+ if (file.kind === "missing")
231
+ notes.push(`(${path} doesn't exist yet; the frame shows it as soon as it is written.)`);
232
+ if (file.kind === "text" && typeof args.start_line === "number") {
233
+ const lines = file.text.split("\n").length - (file.text.endsWith("\n") ? 1 : 0);
234
+ if (args.start_line > lines) throw new Error(`${path} has ${lines} lines`);
168
235
  }
236
+ }
169
237
 
238
+ private relay(sessionId: string, tool: string, args: Record<string, unknown>) {
170
239
  const callId = crypto.randomUUID();
171
240
  const result = new Promise<ToolResult>((resolve) => {
172
241
  const timer = setTimeout(
@@ -182,8 +251,41 @@ export class BoardMcp {
182
251
  "the board isn't open right now: it lives in the host's browser, which is not connected",
183
252
  );
184
253
  }
185
- const answer = await result;
186
- return note && !answer.isError ? done(answer.content[0]!.text + note) : answer;
254
+ return result;
255
+ }
256
+
257
+ /** A new scratch file for `content`; its path. Says so in `notes`. */
258
+ private createScratch(name: unknown, content: unknown, created: string[], notes: string[]) {
259
+ if (typeof content !== "string") throw new Error("content must be text");
260
+ if (typeof name !== "string" || !name.trim())
261
+ throw new Error("content needs a name for its scratch file, e.g. overview.md");
262
+ const path = this.options.scratch.create(name.trim(), content);
263
+ created.push(path);
264
+ const taken = !path.endsWith(`/${name.trim()}`);
265
+ notes.push(`Wrote scratch file ${path}${taken ? ` (${name.trim()} was taken)` : ""}.`);
266
+ return path;
267
+ }
268
+
269
+ private readScratch(path: unknown): string {
270
+ if (typeof path !== "string") throw new Error("path must be a canvas:scratch/… path");
271
+ const file = this.options.scratch.read(path.trim());
272
+ if (file.kind === "text") return file.text;
273
+ if (file.kind === "denied") throw new Error(file.reason);
274
+ if (file.kind === "missing")
275
+ throw new Error(`${path} doesn't exist; view_board lists the scratch files`);
276
+ throw new Error(`${path} can't be read`);
277
+ }
278
+
279
+ private writeScratch(args: Record<string, unknown>): string {
280
+ if (typeof args.path === "string" && args.path.trim()) {
281
+ if (args.name !== undefined) throw new Error("give either path (overwrite) or name (create)");
282
+ if (typeof args.content !== "string") throw new Error("content must be text");
283
+ this.options.scratch.write(args.path.trim(), args.content);
284
+ return `Wrote ${args.path.trim()}; frames showing it update.`;
285
+ }
286
+ const notes: string[] = [];
287
+ this.createScratch(args.name, args.content, [], notes);
288
+ return notes[0]!;
187
289
  }
188
290
  }
189
291
 
@@ -8,11 +8,15 @@
8
8
  * One recursive watcher covers the working dir: editors and agents often
9
9
  * replace files rather than write them in place, and a frame may wait for a
10
10
  * file (or its directory) that does not exist yet.
11
+ *
12
+ * Scratch files (`canvas:scratch/…`, ADR 0005) are read and listed alongside;
13
+ * the watcher skips `.canvas/`, so `Scratch` reports its writes itself.
11
14
  */
12
15
 
13
16
  import { readFileSync, statSync, watch, type FSWatcher } from "node:fs";
14
17
  import { sep } from "node:path";
15
18
  import type { FileContent } from "../shared/protocol";
19
+ import { isScratchPath, Scratch } from "./scratch";
16
20
  import { SharedSet } from "./shared-set";
17
21
 
18
22
  export const MAX_FILE_BYTES = 1024 * 1024;
@@ -35,11 +39,13 @@ export class Files {
35
39
  dir: string,
36
40
  private readonly onFile: (path: string, file: FileContent) => void,
37
41
  private readonly onTree: (paths: ReadonlyArray<string>) => void,
42
+ private readonly scratch: Scratch = new Scratch(dir),
38
43
  ) {
39
44
  this.shared = new SharedSet(dir);
40
45
  }
41
46
 
42
47
  read(path: string): FileContent {
48
+ if (isScratchPath(path)) return this.scratch.read(path);
43
49
  let absolute: string;
44
50
  try {
45
51
  absolute = this.shared.resolve(path);
@@ -83,6 +89,12 @@ export class Files {
83
89
  this.ensureWatcher();
84
90
  }
85
91
 
92
+ /** A scratch file was written: re-send it, and the tree if it is new. */
93
+ scratchChanged(path: string): void {
94
+ if (this.subscribers.has(path)) this.send(path);
95
+ if (this.treeWatched) this.sendTree();
96
+ }
97
+
86
98
  stop(): void {
87
99
  this.watcher?.close();
88
100
  if (this.timer) clearTimeout(this.timer);
@@ -97,7 +109,7 @@ export class Files {
97
109
  }
98
110
 
99
111
  private sendTree() {
100
- const paths = this.shared.list();
112
+ const paths = [...this.shared.list(), ...this.scratch.list()];
101
113
  const key = paths.join("\0");
102
114
  if (key === this.lastTree) return;
103
115
  this.lastTree = key;
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Scratch files (ADR 0005): content an agent writes for the board only —
3
+ * a write-up, an HTML visualisation — kept in `<dir>/.canvas/scratch/`, out
4
+ * of the project. Board paths name them `canvas:scratch/<name>`; they are
5
+ * read like the shared set's files and mirrored the same way.
6
+ *
7
+ * Only agents write them, through their board tools: nothing a browser
8
+ * sends reaches `create` or `write`. One flat namespace; a name is never
9
+ * taken twice, so creating one never overwrites another agent's file.
10
+ */
11
+
12
+ import {
13
+ existsSync,
14
+ mkdirSync,
15
+ readdirSync,
16
+ readFileSync,
17
+ rmSync,
18
+ statSync,
19
+ writeFileSync,
20
+ } from "node:fs";
21
+ import { join } from "node:path";
22
+ import { SCRATCH_PREFIX } from "../shared/board-tools";
23
+ import type { FileContent } from "../shared/protocol";
24
+
25
+ export const MAX_SCRATCH_BYTES = 1024 * 1024;
26
+
27
+ export const isScratchPath = (path: string) => path.startsWith(SCRATCH_PREFIX);
28
+
29
+ /** A name as agents may give it: one plain segment, e.g. `auth-overview.md`. */
30
+ const NAME = /^[\w][\w.-]*$/;
31
+
32
+ export class Scratch {
33
+ private readonly dir: string;
34
+
35
+ constructor(
36
+ root: string,
37
+ /** A scratch file was written: `canvas:scratch/<name>`. */
38
+ private readonly onChange: (path: string) => void = () => {},
39
+ ) {
40
+ this.dir = join(root, ".canvas", "scratch");
41
+ }
42
+
43
+ /** A new file, under `name` or, if that is taken, `name-2`, `name-3`…; its path. */
44
+ create(name: string, text: string): string {
45
+ checkName(name);
46
+ checkSize(text);
47
+ const dot = name.lastIndexOf(".");
48
+ const [stem, ext] = dot > 0 ? [name.slice(0, dot), name.slice(dot)] : [name, ""];
49
+ let free = name;
50
+ for (let n = 2; existsSync(join(this.dir, free)); n++) free = `${stem}-${n}${ext}`;
51
+ mkdirSync(this.dir, { recursive: true });
52
+ writeFileSync(join(this.dir, free), text);
53
+ const path = SCRATCH_PREFIX + free;
54
+ this.onChange(path);
55
+ return path;
56
+ }
57
+
58
+ /** Overwrite an existing scratch file. */
59
+ write(path: string, text: string): void {
60
+ const name = this.name(path);
61
+ if (!existsSync(join(this.dir, name)))
62
+ throw new Error(`${path} doesn't exist; give a name to create it`);
63
+ checkSize(text);
64
+ writeFileSync(join(this.dir, name), text);
65
+ this.onChange(path);
66
+ }
67
+
68
+ /** Undo a `create` whose board call failed. */
69
+ remove(path: string): void {
70
+ rmSync(join(this.dir, this.name(path)), { force: true });
71
+ this.onChange(path);
72
+ }
73
+
74
+ read(path: string): FileContent {
75
+ let name: string;
76
+ try {
77
+ name = this.name(path);
78
+ } catch (error) {
79
+ return { kind: "denied", reason: (error as Error).message };
80
+ }
81
+ const file = join(this.dir, name);
82
+ const stat = statSync(file, { throwIfNoEntry: false });
83
+ if (!stat?.isFile()) return { kind: "missing" };
84
+ if (stat.size > MAX_SCRATCH_BYTES) return { kind: "too-large", size: stat.size };
85
+ return { kind: "text", text: readFileSync(file, "utf8") };
86
+ }
87
+
88
+ /** Every scratch file's path, sorted. */
89
+ list(): string[] {
90
+ if (!existsSync(this.dir)) return [];
91
+ return readdirSync(this.dir, { withFileTypes: true })
92
+ .filter((entry) => entry.isFile() && NAME.test(entry.name))
93
+ .map((entry) => SCRATCH_PREFIX + entry.name)
94
+ .sort();
95
+ }
96
+
97
+ private name(path: string): string {
98
+ if (!isScratchPath(path)) throw new Error(`not a scratch file: ${path}`);
99
+ const name = path.slice(SCRATCH_PREFIX.length);
100
+ checkName(name);
101
+ return name;
102
+ }
103
+ }
104
+
105
+ function checkName(name: string) {
106
+ if (!NAME.test(name) || name.length > 120)
107
+ throw new Error(
108
+ `scratch file names are one plain segment of letters, digits, '.', '_' and '-' (got ${JSON.stringify(name)})`,
109
+ );
110
+ }
111
+
112
+ function checkSize(text: string) {
113
+ if (Buffer.byteLength(text) > MAX_SCRATCH_BYTES)
114
+ throw new Error("scratch files are at most 1 MiB");
115
+ }
@@ -11,6 +11,7 @@ import type { ClientToServer, ServerToClient } from "../shared/protocol";
11
11
  import { AgentManager, detectAgents } from "./agents";
12
12
  import { BoardMcp } from "./board-mcp";
13
13
  import { Files } from "./files";
14
+ import { Scratch } from "./scratch";
14
15
  import { Store } from "./store";
15
16
  import { Terminals } from "./terminals";
16
17
 
@@ -31,15 +32,19 @@ export async function serve(options: ServeOptions) {
31
32
  for (const ws of clients) ws.send(text);
32
33
  };
33
34
 
35
+ // Scratch files are written by agents' board tools; `files` mirrors them.
36
+ const scratch = new Scratch(options.dir, (path) => files.scratchChanged(path));
34
37
  const files = new Files(
35
38
  options.dir,
36
39
  (path, file) => broadcast({ t: "file", path, file }),
37
40
  (paths) => broadcast({ t: "tree", paths }),
41
+ scratch,
38
42
  );
39
43
  process.on("exit", () => files.stop());
40
44
  // Board tools: an agent's call goes to one host browser (the board is there).
41
45
  const boardMcp = new BoardMcp({
42
46
  read: (path) => files.read(path),
47
+ scratch,
43
48
  relay: (call) => {
44
49
  const [ws] = clients;
45
50
  if (!ws) return false;
@@ -14,6 +14,8 @@ export const BOARD_TOOL_NAMES = [
14
14
  "open_frame",
15
15
  "update_frame",
16
16
  "close_frame",
17
+ "read_board_file",
18
+ "write_board_file",
17
19
  ] as const;
18
20
  export type BoardToolName = (typeof BOARD_TOOL_NAMES)[number];
19
21
 
@@ -39,7 +41,25 @@ interface FileTarget {
39
41
  readonly end_line?: number;
40
42
  }
41
43
 
42
- export interface OpenFrameArgs extends Placement, FileTarget {
44
+ /** A new scratch file to show (ADR 0005); `canvas serve` turns it into a `path`. */
45
+ interface ScratchContent {
46
+ readonly name?: string;
47
+ readonly content?: string;
48
+ }
49
+
50
+ /** One entry of a file frame's list (ADR 0005): a file at a display path. */
51
+ export interface FileListEntry extends ScratchContent {
52
+ readonly display: string;
53
+ readonly path?: string;
54
+ readonly start_line?: number;
55
+ readonly end_line?: number;
56
+ }
57
+
58
+ interface FileList {
59
+ readonly files?: ReadonlyArray<FileListEntry>;
60
+ }
61
+
62
+ export interface OpenFrameArgs extends Placement, FileTarget, ScratchContent, FileList {
43
63
  readonly type: FrameKind;
44
64
  readonly title?: string;
45
65
  readonly url?: string;
@@ -47,7 +67,7 @@ export interface OpenFrameArgs extends Placement, FileTarget {
47
67
  readonly draft?: string;
48
68
  }
49
69
 
50
- export interface UpdateFrameArgs extends Placement, FileTarget {
70
+ export interface UpdateFrameArgs extends Placement, FileTarget, ScratchContent, FileList {
51
71
  readonly frame: string;
52
72
  readonly title?: string;
53
73
  readonly url?: string;
@@ -58,6 +78,17 @@ export interface CloseFrameArgs {
58
78
  readonly frame: string;
59
79
  }
60
80
 
81
+ export interface ReadBoardFileArgs {
82
+ readonly path: string;
83
+ }
84
+
85
+ export interface WriteBoardFileArgs extends ScratchContent {
86
+ readonly path?: string;
87
+ }
88
+
89
+ /** Where scratch files live, as board paths name them. */
90
+ export const SCRATCH_PREFIX = "canvas:scratch/";
91
+
61
92
  const placement = {
62
93
  next_to: {
63
94
  type: "string",
@@ -74,12 +105,60 @@ const placement = {
74
105
  const fileTarget = {
75
106
  path: {
76
107
  type: "string",
77
- description: "File path relative to the project root, e.g. src/auth/session.ts.",
108
+ description:
109
+ "File path relative to the project root, e.g. src/auth/session.ts, or a scratch file: " +
110
+ "canvas:scratch/<name>.",
78
111
  },
79
112
  start_line: { type: "integer", minimum: 1, description: "First line to show and highlight." },
80
113
  end_line: { type: "integer", minimum: 1, description: "Last highlighted line (inclusive)." },
81
114
  };
82
115
 
116
+ const scratchContent = {
117
+ content: {
118
+ type: "string",
119
+ description:
120
+ "Instead of path: the text of a new scratch file to show — content for this board only, " +
121
+ "like a write-up or an HTML visualisation. canvas keeps it outside the project.",
122
+ },
123
+ name: {
124
+ type: "string",
125
+ description:
126
+ "With content: the scratch file's name, e.g. auth-overview.md; its extension decides how " +
127
+ "it shows. A taken name gets a suffix; the result says which path you got.",
128
+ },
129
+ };
130
+
131
+ const fileList = {
132
+ files: {
133
+ type: "array",
134
+ description:
135
+ "file: a list of files for the frame's tree, for people to click through at their pace — " +
136
+ "e.g. the files of one feature, with a write-up first. Each entry is a project file or a " +
137
+ "scratch file at a display path you choose: make folders, rename, order it (a folder sorts " +
138
+ "where its first entry is; no folders for a flat list). The frame shows path, else the " +
139
+ "first entry. update_frame replaces the list; [] removes it.",
140
+ items: {
141
+ type: "object",
142
+ properties: {
143
+ display: {
144
+ type: "string",
145
+ description:
146
+ 'Where it shows in the tree, e.g. "1 Overview.md" or "Auth/session.ts". Unique.',
147
+ },
148
+ path: { type: "string", description: "A project file or canvas:scratch/<name>." },
149
+ ...scratchContent,
150
+ start_line: {
151
+ type: "integer",
152
+ minimum: 1,
153
+ description: "Lines to open it at, shown as a badge.",
154
+ },
155
+ end_line: { type: "integer", minimum: 1 },
156
+ },
157
+ required: ["display"],
158
+ },
159
+ },
160
+ };
161
+
83
162
  export const BOARD_TOOLS: ReadonlyArray<{
84
163
  readonly name: BoardToolName;
85
164
  readonly description: string;
@@ -105,7 +184,9 @@ export const BOARD_TOOLS: ReadonlyArray<{
105
184
  name: "open_frame",
106
185
  description:
107
186
  "Open a new frame on the board, in your own cluster unless placed next to another frame. " +
108
- "file: a project file (read-only, live), optionally at a line range which gets highlighted. " +
187
+ "file: a project file (read-only, live), optionally at a line range which gets highlighted, " +
188
+ "or a scratch file: content you pass, kept by canvas outside the project. A file frame can " +
189
+ "also carry a list of files (files): one frame to click through, instead of many. " +
109
190
  "browser: a URL, loaded by each viewer's own browser. terminal: an idle shell people can " +
110
191
  "type into. agent: another agent session; `draft` pre-fills its prompt, a person sends it.",
111
192
  inputSchema: {
@@ -113,6 +194,8 @@ export const BOARD_TOOLS: ReadonlyArray<{
113
194
  properties: {
114
195
  type: { type: "string", enum: ["file", "browser", "terminal", "agent"] },
115
196
  ...fileTarget,
197
+ ...scratchContent,
198
+ ...fileList,
116
199
  url: { type: "string", description: "browser: an http(s) URL." },
117
200
  agent: { type: "string", description: "agent: which agent runs it (see view_board)." },
118
201
  draft: { type: "string", description: "agent: a prompt draft for people to send." },
@@ -125,7 +208,8 @@ export const BOARD_TOOLS: ReadonlyArray<{
125
208
  {
126
209
  name: "update_frame",
127
210
  description:
128
- "Change a frame: point a file frame at another file or line range, switch a markdown " +
211
+ "Change a frame: point a file frame at another file, line range or new scratch file " +
212
+ "(content), give it a list of files or replace it (files), switch a markdown " +
129
213
  "or HTML file between preview and source, change a browser frame's URL, rename a frame, or move " +
130
214
  "it next to another frame.",
131
215
  inputSchema: {
@@ -133,6 +217,8 @@ export const BOARD_TOOLS: ReadonlyArray<{
133
217
  properties: {
134
218
  frame: { type: "string", description: "Id of the frame to change." },
135
219
  ...fileTarget,
220
+ ...scratchContent,
221
+ ...fileList,
136
222
  view: { type: "string", enum: ["preview", "source"] },
137
223
  url: { type: "string" },
138
224
  title: { type: "string" },
@@ -151,6 +237,33 @@ export const BOARD_TOOLS: ReadonlyArray<{
151
237
  required: ["frame"],
152
238
  },
153
239
  },
240
+ {
241
+ name: "read_board_file",
242
+ description:
243
+ "Read a scratch file (canvas:scratch/<name>), e.g. one another agent wrote. view_board " +
244
+ "lists them.",
245
+ inputSchema: {
246
+ type: "object",
247
+ properties: { path: { type: "string", description: "canvas:scratch/<name>" } },
248
+ required: ["path"],
249
+ },
250
+ },
251
+ {
252
+ name: "write_board_file",
253
+ description:
254
+ "Write a scratch file without opening a frame. With name: a new one. With path: overwrite " +
255
+ "an existing one (any agent's); every frame showing it updates. Show a new one with " +
256
+ "open_frame (path).",
257
+ inputSchema: {
258
+ type: "object",
259
+ properties: {
260
+ path: { type: "string", description: "canvas:scratch/<name> to overwrite." },
261
+ name: scratchContent.name,
262
+ content: { type: "string", description: "The file's full text." },
263
+ },
264
+ required: ["content"],
265
+ },
266
+ },
154
267
  ];
155
268
 
156
269
  /** The agent's standing context, sent as MCP server instructions. */
@@ -159,7 +272,7 @@ export function boardInstructions(frameId: string): string {
159
272
 
160
273
  Frames that sit close together form a cluster: people keep related work together that way, and your own cluster is your workspace. Within a cluster frames sit in rows; frames in a row share a height.
161
274
 
162
- The ${BOARD_SERVER_NAME} tools let you see and change the board: ${BOARD_TOOL_NAMES.join(", ")}. Use them when showing something on the board helps the people you work with — e.g. asked to show the files relevant to a topic, open them as file frames at the relevant lines instead of pasting code. Don't use them when a plain answer is enough.
275
+ The ${BOARD_SERVER_NAME} tools let you see and change the board: ${BOARD_TOOL_NAMES.join(", ")}. Use them when showing something on the board helps the people you work with — e.g. asked to show the files relevant to a topic, show them on the board (one file, or a list of them) at the relevant lines instead of pasting code. Don't use them when a plain answer is enough.
163
276
 
164
277
  - Look first (view_board). Reuse or retarget a frame (update_frame) rather than open a duplicate.
165
278
  - New frames go into your cluster by default; that is almost always right. Keep it to a handful per request.
@@ -168,5 +281,8 @@ The ${BOARD_SERVER_NAME} tools let you see and change the board: ${BOARD_TOOL_NA
168
281
  - A terminal frame is an idle shell for people; you cannot type into it. Run commands with your own tools.
169
282
  - An agent frame starts another agent. You can leave a draft prompt in it; only a person can send it.
170
283
  - A browser frame loads its URL in each viewer's own browser, so localhost means their machine, not this one: only the host sees a localhost URL, guests get a notice.
171
- - To show a page you wrote to everyone, open the HTML file as a file frame: it renders, scripts included, but relative links and assets (CSS, images, other scripts) don't load, so inline them.`;
284
+ - Scratch files hold what exists only to be shown on this board: a write-up, a diagram, an HTML visualisation. Pass the text as \`content\` (with a \`name\`) to open_frame or update_frame, or use write_board_file; canvas keeps them outside the project, as canvas:scratch/<name>. Don't write such files into the project for the board. Anything else — a temp file for your own work, a script, test data — goes wherever it would without canvas.
285
+ - Any agent may read (read_board_file) and overwrite (write_board_file) any scratch file; view_board lists them.
286
+ - To show several files for one topic, prefer one file frame with a list (files) over a frame per file: people click through it at their own pace. You decide the tree: display paths, folders, order, line ranges. A write-up (a scratch file) at the top and a visualisation at the bottom fit in the same list.
287
+ - Markdown and HTML files render. HTML runs its scripts, but relative links and assets (CSS, images, other scripts) don't load, so inline them.`;
172
288
  }