{/* 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

INFO

以下是 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/arm64 AppImage 或 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)、bearer token、 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 标签,让它扛过保存且可读以便验证。
  • 在 signal abort 上拆监听器。 跳过这不只是外观问题:下次保存时旧 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 必须带 script manifest:{ "sha256": "<digest>" }, digest 是对每个排序后的 script/ 路径按 `${path}\0${sha256hex(bytes)}\n` 算的 sha256。 不匹配按篡改拒。
  • 把 digest 预信任,加入 ~/.tldraw/script-trust.json ({ "trusted": ["<digest>"] },或 $TLDRAW_SCRIPT_TRUST)。 当 isScriptTrusted(digest) 为真时应用跳过同意。

流程

  1. 从 server.json 读当前 token/端口。用 api.getFocusedDoc() (或 api.getDocs())找目标 doc;开了多个就明确命名。
  2. 布局/生成用 /exec。持久行为经 /script-workspace 编辑 script/main.js。
  3. 让脚本幂等:用 helpers.createShapeIfMissing 和稳定的 createShapeId('name') id 建持久形状。脚本每次加载重跑。
  4. 把脚本拥有的写挡在用户 undo 栈外: editor.run(fn, { history: 'ignore' })(或 helpers.translateShapes,它已这么做)。
  5. 响应式用 editor.store.listen(cb) 并在 signal abort 拆除。 交互用 editor.on('event', h)(在 page 坐标 hit-test pointer_down); 动画用 editor.on('tick', h)。
  6. 单个移动锚点 + 附属内部件,优先 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
noterichText, color, labelColor, size, font, align, verticalAlign, growY, fontSizeAdjustment, url, scale, textLastEditedBy
textrichText, color, size, font, textAlign, w, scale, autoSize
framew, h, name, color
geogeo, 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 次触发; 一个 setTimeout tick 后显示 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。