tldraw offline Skill
Work with the tldraw offline desktop app (offline.tldraw.com): read the open
canvas, make edits, and write document scripts — JavaScript embedded in a
.tldraw file that runs on load and gives the file durable behavior. The app
runs a local HTTP API (default localhost:7236) that a coding agent drives
with plain curl from its terminal — this is exactly how the app's own homepage
demo (Codex editing a canvas live) works. The agent does NOT use computer-use /
GUI clicking, and does NOT hand-edit the .tldraw file directly. Keep tldraw
offline open while you work.
When to Use
- The user has tldraw offline open and asks you to build or modify a canvas
(diagrams, wireframes, layouts).
- You want to add durable behavior to a drawing (reactive shapes, interactive
buttons, animation, connection logic) via an embedded document script.
Do NOT hand-place shapes to imitate a drawing — write the code that generates
them. Agents are far better at scripting the canvas than at drawing on it.
Prerequisites
- tldraw offline installed and running, with a document open. Releases:
https://github.com/tldraw/tldraw-offline/releases/latest (macOS DMG, Windows
x64/Arm64, Linux
x86_64/arm64 AppImage or amd64/arm64 .deb).
- Agent skills installed in the app:
Develop → Install Agent Skills. The
app writes its own tldraw skill into ~/.codex/skills/, ~/.claude/skills/,
~/.cursor/skills/, and ~/.gemini/skills/ — teaching that agent the curl
recipes below. (This Hermes skill mirrors that guidance for Hermes.)
- The local control API. On launch the app writes
server.json to its config
dir (Linux ~/.config/tldraw/, macOS ~/Library/Application Support/tldraw/,
Windows %APPDATA%\tldraw\) with port (default 7236), a bearer token,
pid, and startedAt. Every request except GET / needs
Authorization: Bearer <token>. A clean quit removes server.json; if it's
present but the port doesn't answer, the app quit uncleanly — treat as not
running.
- Re-read port + token on EVERY shell call. Each terminal call is a fresh
shell, so an
exported token does not persist — "export once and reuse" sends
an empty token and 401s. Read both inline at the top of each call:
PORT=$(jq -r .port <server.json>); TOKEN=$(jq -r .token <server.json>).
- No account or network needed for local editing.
How to Run
Two distinct workflows. Pick by whether the change must survive a reload.
A. One-off canvas edits (/exec) — layout, generating shapes, cleanup. This
is a live edit, not saved script:
BASE=http://localhost:7236
TOKEN=$(python -c "import json;print(json.load(open('$HOME/.config/tldraw/server.json'))['token'])")
# find the focused document id
DOC=$(curl -s "$BASE/api/search" -X POST -H 'content-type: application/json' \
-H "Authorization: Bearer $TOKEN" \
-d '{"code":"return (await api.getFocusedDoc()).id"}' | python -c "import sys,json;print(json.load(sys.stdin)['result'])")
# run code with the live `editor` + `helpers` in scope
curl -s "$BASE/api/doc/$DOC/exec" -X POST -H 'content-type: application/json' \
-H "Authorization: Bearer $TOKEN" \
-d '{"code":"const {createShapeId,toRichText}=await import(\"tldraw\"); editor.createShape({id:createShapeId(),type:\"geo\",x:0,y:0,props:{geo:\"rectangle\",w:200,h:100,color:\"blue\",fill:\"solid\",richText:toRichText(\"hello\")}}); return editor.getCurrentPageShapes().length"}'
B. Durable behavior (script/main.js) — reactive/interactive logic that must
survive reload. Edit the file on disk; the app's watcher applies it:
# get the live script file path for the doc
curl -s "$BASE/api/doc/$DOC/script-workspace" -X POST \
-H "Authorization: Bearer $TOKEN" # -> result.mainJsPath, result.isDefaultScript
# edit result.mainJsPath with read_file / patch / write_file (see scripts/main.js)
# then confirm the watcher applied it:
curl -s "$BASE/api/doc/$DOC/script-status" -H "Authorization: Bearer $TOKEN"
The ready-to-adapt document script is scripts/main.js.
Quick Reference
The document-script contract (verified against the app's bundled
script-context.d.ts):
import { createShapeId, toRichText } from 'tldraw' // primitives: import, not globals
export default function ({ editor, helpers, signal }) {
editor.run(() => { // batch = one undo step
helpers.createShapeIfMissing({ // idempotent furniture
id: createShapeId('node-1'), type: 'geo', x: 0, y: 0,
props: { geo: 'rectangle', w: 200, h: 100, richText: toRichText('hi') },
})
})
const stop = editor.store.listen(() => { /* react */ }) // fires the tick AFTER a commit
signal.addEventListener('abort', () => stop()) // REQUIRED cleanup on rerun/close
}
ctx.editor — the live Editor (createShape, updateShape, deleteShapes,
getCurrentPageShapes, getShape, getBindingsFromShape, zoomToFit,
on('tick'|'event', fn), run(fn, { history: 'ignore' })).
ctx.helpers — createShapeIfMissing, createShapesIfMissing,
createArrowBetweenShapes(from, to, { arrowheadEnd }), translateShapes,
onShapeTranslate(id, fn, { signal }), richTextToPlainText, boxShapes,
getLints.
ctx.signal — AbortSignal; attach every listener/interval teardown to it.
config.js (separate file) registers custom shape/tool/component utils and
runs before mount; main.js runs against the mounted editor and reruns on save.
Interactive UI (clickable buttons that drive state)
Drawn shapes can behave like a real app — the thing a static whiteboard can't do.
Full example: scripts/counter.js (a number display + MINUS/RESET/PLUS buttons).
Verification boundary — read this before claiming interaction works or doesn't.
The app's OWN agent playbook says to verify a clickable-UI script with "one
simulated click and one state read" via /exec (editor.dispatch a pointer
event, await a tick, read the shape's state) — NOT by driving a real mouse. By
that standard the counter is verified: dispatched clicks stepped it
0 → 1 → 2 → 1 → 0. Two caveats worth writing down:
- The script only runs once the app's file-watcher applies it. On Linux that
watcher uses inotify; a host with an exhausted
fs.inotify.max_user_instances
logs inotify_add_watch ... No space left on device, script-status shows
state: "not-watching" / hasEntry: false, and the script never executes.
This is a host limit, not a script bug — a normal desktop is unaffected.
- Driving the canvas with computer-use needs FOREGROUND delivery, not
background. cua-driver's default
background delivery returns
background_unavailable against Electron's occluded renderer — but that is the
first rung, not a wall. cua-driver returns an escalation: "foreground" hint;
set delivery_mode: "foreground" (pair with bring_to_front) and its X11 XTest
path (x11_xtest_fg) clicks land — this dismisses the "Run Script" consent
dialog and clicks the canvas. Verified by the Cua team on this exact v1.11.0
AppImage (Linux/X11). Do NOT conclude "Electron rejects synthetic clicks" and
give up in background mode — climb to foreground. (The real product path is
still /exec, not clicks; this note is for computer-use-driven testing.)
The pattern:
export default function ({ editor, helpers, signal }) {
// 1. Build buttons idempotently; tag each with meta so the handler finds them.
// Give buttons a visible label AND a meta.action.
// 2. Hit-test pointer_down in PAGE coordinates against the button bounds:
const inside = (b, p) => p.x >= b.x && p.x <= b.x + b.w && p.y >= b.y && p.y <= b.y + b.h
function onEvent(info) {
if (!info || info.name !== 'pointer_down') return
let p = null
try { if (info.point && editor.screenToPage) p = editor.screenToPage(info.point) } catch {}
p = p ?? editor.inputs?.currentPagePoint
if (!p) return
const hit = editor.getCurrentPageShapes().find(
(s) => s.meta?.ui === 'button' &&
inside({ x: s.x, y: s.y, w: s.props.w, h: s.props.h }, p)
)
if (hit) runAction(hit.meta.action) // mutate state; store it in a shape's meta
}
editor.on('event', onEvent)
signal.addEventListener('abort', () => editor.off('event', onEvent)) // REQUIRED
}
- Find buttons by
meta (or visible label via helpers.richTextToPlainText),
not by hard-coded coordinates.
- One script owns both build and read. If the shapes are created by one code
path (with
meta.action: 'inc') and the handler reads another convention
(meta.action === 'PLUS'), clicks silently do nothing. Ship the buttons built
by the same script that handles them, or ship an empty canvas so the script
builds them fresh — never pre-bake mismatched shapes into the file's db.
- Keep app state in a shape's
meta (e.g. meta.count) and render it as that
shape's richText label, so it survives save and is readable for verification.
- Detach the listener on
signal abort. Skipping this is not cosmetic: on
the next save the old onEvent stays attached alongside the new one, so every
click fires twice and a counter jumps by 2 instead of 1.
- For continuous motion use
editor.on('tick', fn); for a moving anchor with
attached pieces use helpers.onShapeTranslate(id, fn, { signal }).
Shipping a self-running scripted .tldraw
A .tldraw is a zip of metadata.json + session.json + db.sqlite + assets/
script/ (only those entries are packable). For the script to auto-run without
the "This document contains a script → Run Script" consent dialog:
metadata.json must carry a script manifest: { "sha256": "<digest>" }, where
the digest is sha256 over each sorted script/ path as `${path}\0${sha256hex(bytes)}\n`.
A mismatch is rejected as tampered.
- Pre-trust the digest by adding it to
~/.tldraw/script-trust.json
({ "trusted": ["<digest>"] }, or $TLDRAW_SCRIPT_TRUST). The app skips consent
when isScriptTrusted(digest) is true.
Procedure
- Read the current token/port from
server.json. Find the target doc with
api.getFocusedDoc() (or api.getDocs()); name it explicitly if several are
open.
- For layout/generation, use
/exec. For durable behavior, edit
script/main.js via /script-workspace.
- Make scripts idempotent: create durable shapes with
helpers.createShapeIfMissing
and stable createShapeId('name') ids. Scripts rerun on every load.
- Keep script-owned writes out of the user's undo stack:
editor.run(fn, { history: 'ignore' }) (or helpers.translateShapes, which
already does).
- For reactivity,
editor.store.listen(cb) and tear it down on signal abort.
For interaction, editor.on('event', h) (hit-test pointer_down in page
coords); for animation, editor.on('tick', h).
- For a single moving anchor + attached internals, prefer
helpers.onShapeTranslate(anchorId, fn, { signal }) over a broad store
listener — a broad listener can turn your own writes into feedback loops.
Shape props (validated against tldraw SDK v5 schema)
editor.createShape / createShapeIfMissing accept partial props (shape utils
fill defaults). When building raw records for a file snapshot, every prop
below is required (run scripts/validate_shapes.mjs):
| Shape |
Required props |
note |
richText, color, labelColor, size, font, align, verticalAlign, growY, fontSizeAdjustment, url, scale, textLastEditedBy |
text |
richText, color, size, font, textAlign, w, scale, autoSize |
frame |
w, h, name, color |
geo |
geo, w, h, color, fill, richText (+ dash/size/etc. defaulted) |
richText must be toRichText('...') — a bare string is rejected. color enum:
black grey light-violet violet blue light-blue yellow orange green light-green light-red red white. font enum: draw sans serif mono.
Pitfalls
store.listen fires on the tick AFTER a commit, not synchronously. If you
write a shape and immediately read state expecting the listener to have run, it
hasn't. Verified live: an in-turn read shows 0 fires; after one setTimeout
tick it shows 1. Same reason the app notes editor.dispatch is async — await a
tick before verifying.
ctx, not globals. The entry is export default function ({ editor, helpers, signal }). There is no bare editor global in a document script.
createShapeId / toRichText / Vec come from import ... from 'tldraw'.
richText, not text. Text/note/geo labels use richText: toRichText(s).
- Raw records need every prop;
createShape does not. In-app pass only the
props you care about; a hand-built .tldraw snapshot needs the full set (table).
- Scripts rerun on every load — be idempotent. Use
createShapeIfMissing
with stable ids or you duplicate content and clobber user edits.
- Clean up on
signal. signal.addEventListener('abort', () => stop()) for
every store.listen / editor.on / setInterval; the signal fires before
rerun and on close.
- Keep script writes out of undo:
editor.run(fn, { history: 'ignore' }).
editor.on('tick') pauses when the window is hidden (it is a RAF loop);
setInterval keeps firing but Electron throttles it to ~1/s in the background.
- The API needs the bearer token from
server.json; the port can be non-default
(server.listen(0) picks one) — always read the file, don't hardcode 7236.
- Only
tldraw / react / react-dom import — not a Node project.
Verification
- Shape schema (offline, no app):
node scripts/validate_shapes.mjs — builds
the real tldraw schema and validates note/text/frame. Passing prints 3/3.
- Live canvas edits: after
/exec, read back with /api/search →
api.getShapes(docId) (returns { page, viewport, shapes }) and
api.getBindings(docId) (array). Confirm expected shapes/bindings exist. Grab
api.getScreenshot(docId) (returns { filePath, ... }) and inspect the PNG/JPEG
with vision_analyze.
- Durable script applied:
GET /api/doc/:id/script-status. Success is
state: "applied" (currentDiskDigest === lastAppliedDigest === manifestSha256,
pendingApply === false, lastApplyError === null). If it stays "pending"
after a short retry, report that instead of claiming success; "error" means
the apply failed — read errorLogPath.
1---2name: tldraw-offline3description: Drive and script tldraw offline canvases with an agent.4license: MIT5---6
7# tldraw offline Skill
8
9Work with the tldraw offline desktop app (offline.tldraw.com): read the open
10canvas, make edits, and write **document scripts** — JavaScript embedded in a
11`.tldraw` file that runs on load and gives the file durable behavior. The app
12runs a **local HTTP API** (default `localhost:7236`) that a coding agent drives
13with plain `curl` from its terminal — this is exactly how the app's own homepage
14demo (Codex editing a canvas live) works. The agent does NOT use computer-use /
15GUI clicking, and does NOT hand-edit the `.tldraw` file directly. Keep tldraw
16offline open while you work.
17
18## When to Use
19
20- The user has tldraw offline open and asks you to build or modify a canvas
21 (diagrams, wireframes, layouts).
22- You want to add durable behavior to a drawing (reactive shapes, interactive
23 buttons, animation, connection logic) via an embedded document script.
24
25Do NOT hand-place shapes to imitate a drawing — write the code that generates
26them. Agents are far better at scripting the canvas than at drawing on it.
27
28## Prerequisites
29
30- **tldraw offline installed and running**, with a document open. Releases:
31 https://github.com/tldraw/tldraw-offline/releases/latest (macOS DMG, Windows
32 x64/Arm64, Linux `x86_64`/`arm64` AppImage or amd64/arm64 `.deb`).
33- **Agent skills installed in the app**: `Develop → Install Agent Skills`. The
34 app writes its own tldraw skill into `~/.codex/skills/`, `~/.claude/skills/`,
35 `~/.cursor/skills/`, and `~/.gemini/skills/` — teaching that agent the `curl`
36 recipes below. (This Hermes skill mirrors that guidance for Hermes.)
37- **The local control API.** On launch the app writes `server.json` to its config
38 dir (Linux `~/.config/tldraw/`, macOS `~/Library/Application Support/tldraw/`,
39 Windows `%APPDATA%\tldraw\`) with `port` (default `7236`), a bearer `token`,
40 `pid`, and `startedAt`. Every request except `GET /` needs
41 `Authorization: Bearer <token>`. A clean quit removes `server.json`; if it's
42 present but the port doesn't answer, the app quit uncleanly — treat as not
43 running.
44- **Re-read port + token on EVERY shell call.** Each terminal call is a fresh
45 shell, so an `export`ed token does not persist — "export once and reuse" sends
46 an empty token and 401s. Read both inline at the top of each call:
47 `PORT=$(jq -r .port <server.json>); TOKEN=$(jq -r .token <server.json>)`.
48- No account or network needed for local editing.
49
50## How to Run
51
52Two distinct workflows. Pick by whether the change must survive a reload.
53
54**A. One-off canvas edits (`/exec`)** — layout, generating shapes, cleanup. This
55is a live edit, not saved script:
56
57```bash
58BASE=http://localhost:7236
59TOKEN=$(python -c "import json;print(json.load(open('$HOME/.config/tldraw/server.json'))['token'])")
60# find the focused document id
61DOC=$(curl -s "$BASE/api/search" -X POST -H 'content-type: application/json' \
62 -H "Authorization: Bearer $TOKEN" \
63 -d '{"code":"return (await api.getFocusedDoc()).id"}' | python -c "import sys,json;print(json.load(sys.stdin)['result'])")
64# run code with the live `editor` + `helpers` in scope
65curl -s "$BASE/api/doc/$DOC/exec" -X POST -H 'content-type: application/json' \
66 -H "Authorization: Bearer $TOKEN" \
67 -d '{"code":"const {createShapeId,toRichText}=await import(\"tldraw\"); editor.createShape({id:createShapeId(),type:\"geo\",x:0,y:0,props:{geo:\"rectangle\",w:200,h:100,color:\"blue\",fill:\"solid\",richText:toRichText(\"hello\")}}); return editor.getCurrentPageShapes().length"}'
68```
69
70**B. Durable behavior (`script/main.js`)** — reactive/interactive logic that must
71survive reload. Edit the file on disk; the app's watcher applies it:
72
73```bash
74# get the live script file path for the doc
75curl -s "$BASE/api/doc/$DOC/script-workspace" -X POST \
76 -H "Authorization: Bearer $TOKEN" # -> result.mainJsPath, result.isDefaultScript
77# edit result.mainJsPath with read_file / patch / write_file (see scripts/main.js)
78# then confirm the watcher applied it:
79curl -s "$BASE/api/doc/$DOC/script-status" -H "Authorization: Bearer $TOKEN"
80```
81
82The ready-to-adapt document script is `scripts/main.js`.
83
84## Quick Reference
85
86The document-script contract (verified against the app's bundled
87`script-context.d.ts`):
88
89```js
90import { createShapeId, toRichText } from 'tldraw' // primitives: import, not globals
91
92export default function ({ editor, helpers, signal }) {
93 editor.run(() => { // batch = one undo step
94 helpers.createShapeIfMissing({ // idempotent furniture
95 id: createShapeId('node-1'), type: 'geo', x: 0, y: 0,
96 props: { geo: 'rectangle', w: 200, h: 100, richText: toRichText('hi') },
97 })
98 })
99
100 const stop = editor.store.listen(() => { /* react */ }) // fires the tick AFTER a commit
101 signal.addEventListener('abort', () => stop()) // REQUIRED cleanup on rerun/close
102}
103```
104
105- `ctx.editor` — the live `Editor` (`createShape`, `updateShape`, `deleteShapes`,
106 `getCurrentPageShapes`, `getShape`, `getBindingsFromShape`, `zoomToFit`,
107 `on('tick'|'event', fn)`, `run(fn, { history: 'ignore' })`).
108- `ctx.helpers` — `createShapeIfMissing`, `createShapesIfMissing`,
109 `createArrowBetweenShapes(from, to, { arrowheadEnd })`, `translateShapes`,
110 `onShapeTranslate(id, fn, { signal })`, `richTextToPlainText`, `boxShapes`,
111 `getLints`.
112- `ctx.signal` — `AbortSignal`; attach every listener/interval teardown to it.
113- `config.js` (separate file) registers custom shape/tool/component utils and
114 runs before mount; `main.js` runs against the mounted editor and reruns on save.
115
116## Interactive UI (clickable buttons that drive state)
117
118Drawn shapes can behave like a real app — the thing a static whiteboard can't do.
119Full example: `scripts/counter.js` (a number display + MINUS/RESET/PLUS buttons).
120
121Verification boundary — read this before claiming interaction works or doesn't.
122The app's OWN agent playbook says to verify a clickable-UI script with "one
123simulated click and one state read" via `/exec` (`editor.dispatch` a pointer
124event, await a tick, read the shape's state) — NOT by driving a real mouse. By
125that standard the counter is verified: dispatched clicks stepped it
126`0 → 1 → 2 → 1 → 0`. Two caveats worth writing down:
127- **The script only runs once the app's file-watcher applies it.** On Linux that
128 watcher uses inotify; a host with an exhausted `fs.inotify.max_user_instances`
129 logs `inotify_add_watch ... No space left on device`, `script-status` shows
130 `state: "not-watching"` / `hasEntry: false`, and the script never executes.
131 This is a host limit, not a script bug — a normal desktop is unaffected.
132- **Driving the canvas with computer-use needs FOREGROUND delivery, not
133 background.** cua-driver's default `background` delivery returns
134 `background_unavailable` against Electron's occluded renderer — but that is the
135 first rung, not a wall. cua-driver returns an `escalation: "foreground"` hint;
136 set `delivery_mode: "foreground"` (pair with `bring_to_front`) and its X11 XTest
137 path (`x11_xtest_fg`) clicks land — this dismisses the "Run Script" consent
138 dialog and clicks the canvas. Verified by the Cua team on this exact v1.11.0
139 AppImage (Linux/X11). Do NOT conclude "Electron rejects synthetic clicks" and
140 give up in background mode — climb to foreground. (The real product path is
141 still `/exec`, not clicks; this note is for computer-use-driven testing.)
142
143The pattern:
144
145```js
146export default function ({ editor, helpers, signal }) {
147 // 1. Build buttons idempotently; tag each with meta so the handler finds them.
148 // Give buttons a visible label AND a meta.action.
149 // 2. Hit-test pointer_down in PAGE coordinates against the button bounds:
150 const inside = (b, p) => p.x >= b.x && p.x <= b.x + b.w && p.y >= b.y && p.y <= b.y + b.h
151 function onEvent(info) {
152 if (!info || info.name !== 'pointer_down') return
153 let p = null
154 try { if (info.point && editor.screenToPage) p = editor.screenToPage(info.point) } catch {}
155 p = p ?? editor.inputs?.currentPagePoint
156 if (!p) return
157 const hit = editor.getCurrentPageShapes().find(
158 (s) => s.meta?.ui === 'button' &&
159 inside({ x: s.x, y: s.y, w: s.props.w, h: s.props.h }, p)
160 )
161 if (hit) runAction(hit.meta.action) // mutate state; store it in a shape's meta
162 }
163 editor.on('event', onEvent)
164 signal.addEventListener('abort', () => editor.off('event', onEvent)) // REQUIRED
165}
166```
167
168- Find buttons by `meta` (or visible label via `helpers.richTextToPlainText`),
169 not by hard-coded coordinates.
170- **One script owns both build and read.** If the shapes are created by one code
171 path (with `meta.action: 'inc'`) and the handler reads another convention
172 (`meta.action === 'PLUS'`), clicks silently do nothing. Ship the buttons built
173 by the same script that handles them, or ship an empty canvas so the script
174 builds them fresh — never pre-bake mismatched shapes into the file's db.
175- Keep app state in a shape's `meta` (e.g. `meta.count`) and render it as that
176 shape's `richText` label, so it survives save and is readable for verification.
177- **Detach the listener on `signal` abort.** Skipping this is not cosmetic: on
178 the next save the old `onEvent` stays attached alongside the new one, so every
179 click fires twice and a counter jumps by 2 instead of 1.
180- For continuous motion use `editor.on('tick', fn)`; for a moving anchor with
181 attached pieces use `helpers.onShapeTranslate(id, fn, { signal })`.
182
183### Shipping a self-running scripted `.tldraw`
184
185A `.tldraw` is a zip of `metadata.json` + `session.json` + `db.sqlite` + `assets/`
186+ `script/` (only those entries are packable). For the script to auto-run without
187the "This document contains a script → Run Script" consent dialog:
188
189- `metadata.json` must carry a `script` manifest: `{ "sha256": "<digest>" }`, where
190 the digest is `sha256` over each sorted `script/` path as `` `${path}\0${sha256hex(bytes)}\n` ``.
191 A mismatch is rejected as tampered.
192- Pre-trust the digest by adding it to `~/.tldraw/script-trust.json`
193 (`{ "trusted": ["<digest>"] }`, or `$TLDRAW_SCRIPT_TRUST`). The app skips consent
194 when `isScriptTrusted(digest)` is true.
195
196## Procedure
197
1981. Read the current token/port from `server.json`. Find the target doc with
199 `api.getFocusedDoc()` (or `api.getDocs()`); name it explicitly if several are
200 open.
2012. For layout/generation, use `/exec`. For durable behavior, edit
202 `script/main.js` via `/script-workspace`.
2033. Make scripts idempotent: create durable shapes with `helpers.createShapeIfMissing`
204 and stable `createShapeId('name')` ids. Scripts rerun on every load.
2054. Keep script-owned writes out of the user's undo stack:
206 `editor.run(fn, { history: 'ignore' })` (or `helpers.translateShapes`, which
207 already does).
2085. For reactivity, `editor.store.listen(cb)` and tear it down on `signal` abort.
209 For interaction, `editor.on('event', h)` (hit-test `pointer_down` in page
210 coords); for animation, `editor.on('tick', h)`.
2116. For a single moving anchor + attached internals, prefer
212 `helpers.onShapeTranslate(anchorId, fn, { signal })` over a broad store
213 listener — a broad listener can turn your own writes into feedback loops.
214
215## Shape props (validated against tldraw SDK v5 schema)
216
217`editor.createShape` / `createShapeIfMissing` accept partial props (shape utils
218fill defaults). When building **raw records** for a file snapshot, every prop
219below is required (run `scripts/validate_shapes.mjs`):
220
221| Shape | Required props |
222|-------|----------------|
223| `note` | `richText`, `color`, `labelColor`, `size`, `font`, `align`, `verticalAlign`, `growY`, `fontSizeAdjustment`, `url`, `scale`, `textLastEditedBy` |
224| `text` | `richText`, `color`, `size`, `font`, `textAlign`, `w`, `scale`, `autoSize` |
225| `frame` | `w`, `h`, `name`, `color` |
226| `geo` | `geo`, `w`, `h`, `color`, `fill`, `richText` (+ dash/size/etc. defaulted) |
227
228`richText` must be `toRichText('...')` — a bare string is rejected. `color` enum:
229`black grey light-violet violet blue light-blue yellow orange green light-green
230light-red red white`. `font` enum: `draw sans serif mono`.
231
232## Pitfalls
233
234- **`store.listen` fires on the tick AFTER a commit, not synchronously.** If you
235 write a shape and immediately read state expecting the listener to have run, it
236 hasn't. Verified live: an in-turn read shows 0 fires; after one `setTimeout`
237 tick it shows 1. Same reason the app notes `editor.dispatch` is async — await a
238 tick before verifying.
239- **`ctx`, not globals.** The entry is `export default function ({ editor,
240 helpers, signal })`. There is no bare `editor` global in a document script.
241 `createShapeId` / `toRichText` / `Vec` come from `import ... from 'tldraw'`.
242- **`richText`, not `text`.** Text/note/geo labels use `richText: toRichText(s)`.
243- **Raw records need every prop; `createShape` does not.** In-app pass only the
244 props you care about; a hand-built `.tldraw` snapshot needs the full set (table).
245- **Scripts rerun on every load — be idempotent.** Use `createShapeIfMissing`
246 with stable ids or you duplicate content and clobber user edits.
247- **Clean up on `signal`.** `signal.addEventListener('abort', () => stop())` for
248 every `store.listen` / `editor.on` / `setInterval`; the signal fires before
249 rerun and on close.
250- **Keep script writes out of undo:** `editor.run(fn, { history: 'ignore' })`.
251- **`editor.on('tick')` pauses when the window is hidden** (it is a RAF loop);
252 `setInterval` keeps firing but Electron throttles it to ~1/s in the background.
253- **The API needs the bearer token** from `server.json`; the port can be non-default
254 (`server.listen(0)` picks one) — always read the file, don't hardcode `7236`.
255- **Only `tldraw` / `react` / `react-dom` import** — not a Node project.
256
257## Verification
258
259- **Shape schema (offline, no app):** `node scripts/validate_shapes.mjs` — builds
260 the real tldraw schema and validates note/text/frame. Passing prints `3/3`.
261- **Live canvas edits:** after `/exec`, read back with `/api/search` →
262 `api.getShapes(docId)` (returns `{ page, viewport, shapes }`) and
263 `api.getBindings(docId)` (array). Confirm expected shapes/bindings exist. Grab
264 `api.getScreenshot(docId)` (returns `{ filePath, ... }`) and inspect the PNG/JPEG
265 with `vision_analyze`.
266- **Durable script applied:** `GET /api/doc/:id/script-status`. Success is
267 `state: "applied"` (`currentDiskDigest === lastAppliedDigest === manifestSha256`,
268 `pendingApply === false`, `lastApplyError === null`). If it stays `"pending"`
269 after a short retry, report that instead of claiming success; `"error"` means
270 the apply failed — read `errorLogPath`.