{/* This page is auto-generated from the skill's SKILL.md by website/scripts/generate-skill-docs.py. Edit the source SKILL.md, not this page. */}
Tldraw Offline
用 agent 驱动和脚本化 tldraw 离线画布。
Skill 元数据
| 来源 | 可选 — 通过 hermes skills install official/creative/tldraw-offline 安装 |
| 路径 | optional-skills/creative/tldraw-offline |
| 版本 | 1.0.0 |
| 作者 | Teknium + Hermes Agent |
| 许可证 | MIT |
| 平台 | linux, macos, windows |
| 标签 | tldraw, canvas, whiteboard, document-script, diagramming |
参考:完整 SKILL.md
以下是 Hermes 在触发本 skill 时加载的完整 skill 定义。这是 agent 在 skill 激活时所看到的指令内容。
tldraw offline Skill
与 tldraw 离线桌面应用(offline.tldraw.com)协作:读取打开的
画布、做编辑、写文档脚本——嵌入 .tldraw 文件、加载时运行、
赋予文件持久行为的 JavaScript。应用跑一个本地 HTTP API
(默认 localhost:7236),编码 agent 在终端用普通 curl 驱动——
这正是应用自家首页 demo(Codex 实时编辑画布)的工作方式。agent
不用 computer-use / GUI 点击,也不直接手编 .tldraw 文件。
工作时保持 tldraw offline 打开。
何时使用
- 用户开着 tldraw offline,让你构建或修改画布 (图、线框、布局)。
- 你想经嵌入的文档脚本给一张图加持久行为(响应式形状、可交互 按钮、动画、连接逻辑)。
不要手摆形状去模仿一张图——写生成它们的代码。agent 脚本化画布远强于在上面画图。
前置条件
- tldraw offline 已安装并运行,且有文档打开。发布页:
https://github.com/tldraw/tldraw-offline/releases/latest(macOS DMG、Windows
x64/Arm64、Linux
x86_64/arm64AppImage 或 amd64/arm64.deb)。 - 应用内已装 agent skills:
Develop → Install Agent Skills。应用把它自己的 tldraw skill 写进~/.codex/skills/、~/.claude/skills/、~/.cursor/skills/、~/.gemini/skills/—— 教那个 agent 下面的curl配方。(本 Hermes skill 为 Hermes 镜像该指引。) - 本地控制 API。 启动时应用把
server.json写到其配置目录 (Linux~/.config/tldraw/、macOS~/Library/Application Support/tldraw/、 Windows%APPDATA%\tldraw\),含port(默认7236)、bearertoken、pid、startedAt。除GET /外每个请求都要Authorization: Bearer <token>。正常退出会删server.json;若它在但端口不响应, 应用退出不干净——当作没运行。 - 每次 shell 调用重读端口 + token。 每次终端调用都是新 shell,
所以
export的 token 不持久——"export 一次复用"会发空 token 并 401。 每次调用顶部内联读两者:PORT=$(jq -r .port <server.json>); TOKEN=$(jq -r .token <server.json>)。 - 本地编辑无需账号或网络。
如何运行
两种不同工作流。按改动是否需扛过一次重载来选。
A. 一次性画布编辑(/exec)——布局、生成形状、清理。这是实时编辑,非保存的脚本:
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. 持久行为(script/main.js)——必须扛过重载的响应式/交互逻辑。编辑磁盘上的文件;应用的 watcher 应用它:
# 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"
可直接改编的文档脚本是 scripts/main.js。
速查
文档脚本契约(对照应用自带 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——实时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;把每个监听器/interval 清理挂到它上面。config.js(独立文件)注册自定义 shape/tool/component utils,在 mount 前跑;main.js对已 mount 的 editor 跑,保存时重跑。
可交互 UI(驱动状态的可点按钮)
画出来的形状能像真 app 一样行事——静态白板做不到的事。完整示例:scripts/counter.js(数字显示 + MINUS/RESET/PLUS 按钮)。
验证边界——在宣称交互行不行之前先读这个。
应用自己的 agent playbook 说,用 /exec 经"一次模拟点击 + 一次状态读回"
(editor.dispatch 一个 pointer 事件、等一个 tick、读形状状态)验证可点 UI 脚本——
而非驱动真鼠标。按此标准 counter 已验证:dispatched 点击让它
0 → 1 → 2 → 1 → 0。两条值得写下的注意:
- 脚本只在应用文件 watcher 应用它后才跑。 Linux 上该 watcher 用 inotify;
fs.inotify.max_user_instances耗尽的主机会日志inotify_add_watch ... No space left on device,script-status显示state: "not-watching"/hasEntry: false,脚本永不执行。 这是主机限制,不是脚本 bug——普通桌面不受影响。 - 用 computer-use 驱动画布需要前台投递,不是后台。
cua-driver 默认
background投递对着 Electron 被遮挡渲染器返回background_unavailable——但那是第一阶,不是墙。cua-driver 返回escalation: "foreground"提示;设delivery_mode: "foreground"(配bring_to_front),其 X11 XTest 路径(x11_xtest_fg)点击就能落地—— 这会关掉"Run Script"同意对话框并点击画布。由 Cua 团队在这个确切 v1.11.0 AppImage(Linux/X11)上验证。不要得出"Electron 拒合成点击"然后在后台模式放弃—— 爬到前台。(真正产品路径仍是/exec,不是点击;本注是给 computer-use 驱动测试的。)
模式:
export default function ({ editor, helpers, signal }) {
// 1. 幂等地建按钮;给每个打 meta 标签以便 handler 找到它们。
// 给按钮可见标签 AND meta.action。
// 2. 在 PAGE 坐标里对按钮边界 hit-test pointer_down:
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
}
- 按
meta(或经helpers.richTextToPlainText的可见标签)找按钮, 不按硬编码坐标。 - 一个脚本同时拥有构建和读取。 若形状由一条代码路径建(带
meta.action: 'inc'),而 handler 读另一种约定(meta.action === 'PLUS'), 点击静默无效。让处理按钮的脚本同时建按钮,或发空画布让脚本全新构建—— 绝不把不匹配形状预烤进文件 db。 - 把 app 状态存在形状的
meta(如meta.count)里,渲染为该形状的richText标签,让它扛过保存且可读以便验证。 - 在
signalabort 上拆监听器。 跳过这不只是外观问题:下次保存时旧onEvent与新的并存,每次点击触发两次,计数器跳 2 而非 1。 - 连续运动用
editor.on('tick', fn);带附件的移动锚点用helpers.onShapeTranslate(id, fn, { signal })。
交付自运行脚本化 .tldraw
.tldraw 是 metadata.json + session.json + db.sqlite + assets/
script/的 zip(只有这些条目可打包)。要脚本自动跑而无 "This document contains a script → Run Script" 同意对话框:
metadata.json必须带scriptmanifest:{ "sha256": "<digest>" }, digest 是对每个排序后的script/路径按`${path}\0${sha256hex(bytes)}\n`算的sha256。 不匹配按篡改拒。- 把 digest 预信任,加入
~/.tldraw/script-trust.json({ "trusted": ["<digest>"] },或$TLDRAW_SCRIPT_TRUST)。 当isScriptTrusted(digest)为真时应用跳过同意。
流程
- 从
server.json读当前 token/端口。用api.getFocusedDoc()(或api.getDocs())找目标 doc;开了多个就明确命名。 - 布局/生成用
/exec。持久行为经/script-workspace编辑script/main.js。 - 让脚本幂等:用
helpers.createShapeIfMissing和稳定的createShapeId('name')id 建持久形状。脚本每次加载重跑。 - 把脚本拥有的写挡在用户 undo 栈外:
editor.run(fn, { history: 'ignore' })(或helpers.translateShapes,它已这么做)。 - 响应式用
editor.store.listen(cb)并在signalabort 拆除。 交互用editor.on('event', h)(在 page 坐标 hit-testpointer_down); 动画用editor.on('tick', h)。 - 单个移动锚点 + 附属内部件,优先
helpers.onShapeTranslate(anchorId, fn, { signal })而非宽 store 监听器—— 宽监听器会把你自己的写变成反馈环。
Shape props(对照 tldraw SDK v5 schema 验证)
editor.createShape / createShapeIfMissing 接受部分 props(shape utils
填默认)。为文件快照构建裸记录时,下面每个 prop 都必需(跑
scripts/validate_shapes.mjs):
| Shape | 必需 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 等走默认) |
richText 必须是 toRichText('...')——裸字符串被拒。color 枚举:
black grey light-violet violet blue light-blue yellow orange green light-green light-red red white。font 枚举:draw sans serif mono。
常见坑
store.listen在 commit 之后的 tick 触发,不是同步。 若你写个形状 立刻读状态指望监听器已跑,它没跑。实时验证:回合内读显示 0 次触发; 一个setTimeouttick 后显示 1。同原因应用注明editor.dispatch异步—— 验证前等一个 tick。ctx,不是全局。 入口是export default function ({ editor, helpers, signal })。文档脚本里没有裸editor全局。createShapeId/toRichText/Vec来自import ... from 'tldraw'。richText,不是text。 文本/note/geo 标签用richText: toRichText(s)。- 裸记录需要每个 prop;
createShape不需要。 应用内只传你在意的 props; 手建.tldraw快照需要全组(见上表)。 - 脚本每次加载重跑——要幂等。 用带稳定 id 的
createShapeIfMissing, 否则重复内容并盖掉用户编辑。 - 在
signal上清理。 每个store.listen/editor.on/setInterval都signal.addEventListener('abort', () => stop());信号在重跑前和关闭时触发。 - 把脚本写挡在 undo 外:
editor.run(fn, { history: 'ignore' })。 - 窗口隐藏时
editor.on('tick')暂停(它是 RAF 循环);setInterval持续触发但 Electron 在后台把它节流到约 1/s。 - API 需要
server.json的 bearer token;端口可能非默认 (server.listen(0)会挑一个)——永远读文件,别硬编码7236。 - 只
tldraw/react/react-dom可 import——不是 Node 项目。
验证
- Shape schema(离线,无应用):
node scripts/validate_shapes.mjs—— 构建真 tldraw schema 并验证 note/text/frame。通过打印3/3。 - 实时画布编辑:
/exec后,用/api/search→api.getShapes(docId)(返回{ page, viewport, shapes })和api.getBindings(docId)(数组)读回。确认预期形状/绑定存在。抓api.getScreenshot(docId)(返回{ filePath, ... })并用vision_analyze检视 PNG/JPEG。 - 持久脚本已应用:
GET /api/doc/:id/script-status。成功是state: "applied"(currentDiskDigest === lastAppliedDigest === manifestSha256、pendingApply === false、lastApplyError === null)。短重试后仍"pending", 如实报告而非宣称成功;"error"表示应用失败——读errorLogPath。