Tldraw Offline
Управление офлайн-холстами tldraw и написание скриптов к ним силами агента.
Метаданные навыка
| Источник | Опциональный — установка: 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 Agent загружает при его активации. Именно этот текст агент получает как инструкцию в момент работы навыка.
Навык tldraw offline
Работа с офлайновым десктопным приложением tldraw (offline.tldraw.com): читать
открытый холст, вносить правки и писать скрипты документа — JavaScript,
встроенный в файл .tldraw, который выполняется при загрузке и придаёт файлу
постоянное поведение. Приложение поднимает локальный HTTP API (по умолчанию
localhost:7236), которым кодовый агент управляет обычным curl из терминала.
Ровно так работает и демонстрация на главной странице приложения, где Codex
правит холст вживую. Агент НЕ пользуется computer-use и кликами по интерфейсу и
НЕ правит файл .tldraw руками. Держи tldraw offline открытым, пока работаешь.
Когда использовать
- У пользователя открыт tldraw offline, и он просит собрать или изменить холст (диаграммы, вайрфреймы, раскладки).
- Нужно придать рисунку постоянное поведение — реактивные фигуры, интерактивные кнопки, анимацию, логику связей — через встроенный скрипт документа.
НЕ расставляй фигуры руками, имитируя рисунок, — пиши код, который их порождает. Скриптовать холст агенты умеют куда лучше, чем на нём рисовать.
Что потребуется
- Установленный и запущенный tldraw offline с открытым документом. Релизы:
https://github.com/tldraw/tldraw-offline/releases/latest (DMG для macOS, x64 и
Arm64 для Windows, AppImage
x86_64/arm64либо.debamd64/arm64 для Linux). - Навыки агента, установленные в приложении:
Develop → Install Agent Skills. Приложение кладёт собственный навык tldraw в~/.codex/skills/,~/.claude/skills/,~/.cursor/skills/и~/.gemini/skills/, обучая соответствующего агента рецептам сcurlиз этого документа. (Наш навык повторяет те же указания для 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удаляется; если файл есть, а порт молчит, значит приложение завершилось нештатно — считай, что оно не запущено. - Читай порт и токен на КАЖДОМ вызове шелла. Каждый вызов терминала — новый
шелл, поэтому экспортированный токен не сохраняется: приём «экспортировал один
раз и переиспользую» отправит пустой токен и получит 401. Читай оба прямо в
начале каждого вызова:
PORT=$(jq -r .port <server.json>); TOKEN=$(jq -r .token <server.json>). - Для локального редактирования ни аккаунт, ни сеть не нужны.
Порядок работы
Два разных сценария. Выбирай по тому, должно ли изменение пережить перезагрузку.
А. Разовые правки холста (/exec) — раскладка, генерация фигур, уборка. Это
живая правка, а не сохранённый скрипт:
BASE=http://localhost:7236
TOKEN=$(python -c "import json;print(json.load(open('$HOME/.config/tldraw/server.json'))['token'])")
# находим 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'])")
# выполняем код, имея в области видимости живые editor и helpers
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"}'
Б. Постоянное поведение (script/main.js) — реактивная и интерактивная
логика, которая обязана пережить перезагрузку. Правь файл на диске, наблюдатель
приложения его применит:
# получаем путь к файлу скрипта документа
curl -s "$BASE/api/doc/$DOC/script-workspace" -X POST \
-H "Authorization: Bearer $TOKEN" # -> result.mainJsPath, result.isDefaultScript
# правим result.mainJsPath через read_file / patch / write_file (см. scripts/main.js)
# затем убеждаемся, что наблюдатель применил изменения:
curl -s "$BASE/api/doc/$DOC/script-status" -H "Authorization: Bearer $TOKEN"
Готовый к переделке скрипт документа лежит в scripts/main.js.
Краткая шпаргалка
Контракт скрипта документа (сверен с поставляемым в приложении
script-context.d.ts):
import { createShapeId, toRichText } from 'tldraw' // примитивы импортируются, а не глобальные объекты
export default function ({ editor, helpers, signal }) {
editor.run(() => { // пакет = один шаг отмены
helpers.createShapeIfMissing({ // идемпотентные базовые фигуры
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 */ }) // срабатывает на такте ПОСЛЕ коммита
signal.addEventListener('abort', () => stop()) // ОБЯЗАТЕЛЬНАЯ очистка при перезапуске или закрытии
}
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; вешай на него снятие каждого слушателя и интервала.config.js(отдельный файл) регистрирует свои утилиты фигур, инструментов и компонентов и выполняется до монтирования;main.jsработает уже с примонтированным редактором и перезапускается при каждом сохранении.
Интерактивный интерфейс (кнопки, меняющие состояние)
Нарисованные фигуры могут вести себя как настоящее приложение — то, чего
статическая доска не умеет. Полный пример: scripts/counter.js (число и кнопки
MINUS, RESET, PLUS).
Граница проверки — прочитай это до того, как заявлять, что взаимодействие работает
или не работает. СОБСТВЕННЫЙ сценарий приложения для агентов предписывает
проверять скрипт с кликабельным интерфейсом «одним симулированным кликом и одним
чтением состояния» через /exec (отправить событие указателя через
editor.dispatch, дождаться такта, прочитать состояние фигуры), а НЕ настоящей
мышью. По этому критерию счётчик проверен: отправленные клики провели его по
цепочке 0 → 1 → 2 → 1 → 0. Две оговорки стоит записать:
- Скрипт запускается, только когда его применил файловый наблюдатель
приложения. На Linux наблюдатель работает через inotify, и на хосте с
исчерпанным
fs.inotify.max_user_instancesв лог падаетinotify_add_watch ... No space left on device,script-statusпоказываетstate: "not-watching"иhasEntry: false, а скрипт не выполняется никогда. Это ограничение хоста, а не баг скрипта — обычного десктопа не касается. - Управлять холстом через computer-use нужно доставкой на ПЕРЕДНЕМ ПЛАНЕ, а не
в фоне. Фоновая доставка cua-driver по умолчанию возвращает
background_unavailableпротив перекрытого рендерера Electron — но это первая ступень, а не стена. cua-driver подсказываетescalation: "foreground": выставьтеdelivery_mode: "foreground"(вместе сbring_to_front), и клики через путь X11 XTest (x11_xtest_fg) дойдут — так закрывается диалог согласия «Run Script» и происходят клики по холсту. Проверено командой Cua на этом самом AppImage v1.11.0 (Linux, X11). НЕ делайте вывод «Electron не принимает синтетические клики» и не сдавайтесь в фоновом режиме — поднимитесь на передний план. (Настоящий рабочий путь всё равно/exec, а не клики; эта заметка про тестирование через computer-use.)
Схема такая:
export default function ({ editor, helpers, signal }) {
// 1. Создавайте кнопки идемпотентно; помечайте каждую через meta, чтобы обработчик их находил.
// Давайте кнопке видимую подпись И meta.action.
// 2. Проверяйте попадание 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) // меняем состояние; храним его в meta фигуры
}
editor.on('event', onEvent)
signal.addEventListener('abort', () => editor.off('event', onEvent)) // ОБЯЗАТЕЛЬНО
}
- Ищи кнопки по
meta(или по видимой подписи черезhelpers.richTextToPlainText), а не по зашитым координатам. - Один скрипт владеет и созданием, и чтением. Если фигуры создаёт один код (с
meta.action: 'inc'), а обработчик читает другое соглашение (meta.action === 'PLUS'), клики молча ничего не делают. Поставляйте кнопки, созданные тем же скриптом, который их обрабатывает, либо поставляйте пустой холст, чтобы скрипт создал их с нуля, — но никогда не запекайте в базу файла фигуры, не совпадающие с обработчиком. - Состояние приложения держите в
metaфигуры (например,meta.count) и рисуйте его подписьюrichTextтой же фигуры: так оно переживёт сохранение и будет читаемо при проверке. - Снимайте слушателя по прерыванию
signal. Это не косметика: при следующем сохранении старыйonEventостанется висеть рядом с новым, каждый клик сработает дважды, и счётчик прыгнет на 2 вместо 1. - Для непрерывного движения берите
editor.on('tick', fn); для подвижного якоря с прицепленными частями —helpers.onShapeTranslate(id, fn, { signal }).
Как отдать самозапускающийся .tldraw со скриптом
Файл .tldraw — это zip из metadata.json, session.json, db.sqlite,
assets/ и script/ (упаковать можно только эти записи). Чтобы скрипт запускался
сам, без диалога согласия «This document contains a script → Run Script»:
- в
metadata.jsonдолжен лежать манифестscript:{ "sha256": "<digest>" }, где дайджест считается какsha256по каждому отсортированному пути внутриscript/в виде`${path}\0${sha256hex(bytes)}\n`. Несовпадение считается подделкой и отвергается; - заранее доверьте дайджесту, добавив его в
~/.tldraw/script-trust.json({ "trusted": ["<digest>"] }либо через$TLDRAW_SCRIPT_TRUST). Приложение пропускает согласие, когдаisScriptTrusted(digest)истинно.
Процедура
- Прочитай текущие токен и порт из
server.json. Найди нужный документ черезapi.getFocusedDoc()(илиapi.getDocs()); если открыто несколько, называй документ явно. - Для раскладки и генерации используй
/exec. Для постоянного поведения правьscript/main.jsчерез/script-workspace. - Делай скрипты идемпотентными: постоянные фигуры создавай через
helpers.createShapeIfMissingсо стабильными идентификаторамиcreateShapeId('name'). Скрипты перезапускаются при каждой загрузке. - Держи записи скрипта вне стека отмены пользователя:
editor.run(fn, { history: 'ignore' })(либоhelpers.translateShapes, который уже так делает). - Для реактивности бери
editor.store.listen(cb)и снимай его по прерываниюsignal. Для взаимодействия —editor.on('event', h)с проверкой попаданияpointer_downв координатах страницы; для анимации —editor.on('tick', h). - Для одного подвижного якоря с прицепленным содержимым предпочитай
helpers.onShapeTranslate(anchorId, fn, { signal })широкому слушателю хранилища: широкий слушатель легко превращает твои же записи в петлю обратной связи.
Свойства фигур (сверено со схемой tldraw SDK v5)
editor.createShape и createShapeIfMissing принимают частичные свойства —
утилиты фигур подставят умолчания. Но когда собираешь сырые записи для снимка
файла, обязательны все свойства из таблицы (проверка: scripts/validate_shapes.mjs):
| Фигура | Обязательные свойства |
|---|---|
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срабатывает на такте ПОСЛЕ коммита, а не синхронно. Если записать фигуру и тут же прочитать состояние в расчёте на отработавший слушатель, он ещё не отработал. Проверено вживую: чтение внутри того же хода показывает ноль срабатываний, а после одного тактаsetTimeout— одно. По той же причине приложение отмечает асинхронностьeditor.dispatch: дождитесь такта перед проверкой.- Работайте через
ctx, а не через глобальные объекты. Точка входа —export default function ({ editor, helpers, signal }). Никакого глобальногоeditorв скрипте документа нет.createShapeId,toRichTextиVecберутся изimport ... from 'tldraw'. richText, а неtext. Подписи text, note и geo используютrichText: toRichText(s).- Сырым записям нужны все свойства, а
createShape— нет. Внутри приложения передавайте только интересующие вас свойства; собранному руками снимку.tldrawнужен полный набор из таблицы. - Скрипты перезапускаются при каждой загрузке — будьте идемпотентны.
Используйте
createShapeIfMissingсо стабильными идентификаторами, иначе наплодите дублей и затрёте правки пользователя. - Прибирайтесь по
signal.signal.addEventListener('abort', () => stop())для каждогоstore.listen,editor.onиsetInterval; сигнал срабатывает перед перезапуском и при закрытии. - Держите записи скрипта вне отмены:
editor.run(fn, { history: 'ignore' }). editor.on('tick')замирает, когда окно скрыто — это цикл на RAF;setIntervalпродолжает срабатывать, но Electron в фоне душит его примерно до одного раза в секунду.- API требует bearer-токен из
server.json, а порт может быть нестандартным (server.listen(0)выбирает свободный) — всегда читайте файл, не зашивайте7236. - Импортировать можно только
tldraw,reactиreact-dom— это не Node-проект.
Проверка
- Схема фигур (офлайн, без приложения):
node scripts/validate_shapes.mjs— собирает настоящую схему tldraw и проверяет note, text и frame. Успех печатает3/3. - Живые правки холста: после
/execвычитайте результат через/api/search→api.getShapes(docId)(возвращает{ page, viewport, shapes }) иapi.getBindings(docId)(массив). Убедитесь, что ожидаемые фигуры и связи на месте. Возьмитеapi.getScreenshot(docId)(возвращает{ filePath, ... }) и посмотрите PNG или JPEG черезvision_analyze. - Постоянный скрипт применён:
GET /api/doc/:id/script-status. Успех — этоstate: "applied"(currentDiskDigest === lastAppliedDigest === manifestSha256,pendingApply === false,lastApplyError === null). Если после короткой повторной проверки состояние держится на"pending", так и сообщите, а не объявляйте успех;"error"означает сбой применения — читайтеerrorLogPath.