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 либо .deb amd64/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) истинно.

Процедура

  1. Прочитай текущие токен и порт из server.json. Найди нужный документ через api.getFocusedDoc() (или api.getDocs()); если открыто несколько, называй документ явно.
  2. Для раскладки и генерации используй /exec. Для постоянного поведения правь script/main.js через /script-workspace.
  3. Делай скрипты идемпотентными: постоянные фигуры создавай через helpers.createShapeIfMissing со стабильными идентификаторами createShapeId('name'). Скрипты перезапускаются при каждой загрузке.
  4. Держи записи скрипта вне стека отмены пользователя: editor.run(fn, { history: 'ignore' }) (либо helpers.translateShapes, который уже так делает).
  5. Для реактивности бери editor.store.listen(cb) и снимай его по прерыванию signal. Для взаимодействия — editor.on('event', h) с проверкой попадания pointer_down в координатах страницы; для анимации — editor.on('tick', h).
  6. Для одного подвижного якоря с прицепленным содержимым предпочитай helpers.onShapeTranslate(anchorId, fn, { signal }) широкому слушателю хранилища: широкий слушатель легко превращает твои же записи в петлю обратной связи.

Свойства фигур (сверено со схемой tldraw SDK v5)

editor.createShape и createShapeIfMissing принимают частичные свойства — утилиты фигур подставят умолчания. Но когда собираешь сырые записи для снимка файла, обязательны все свойства из таблицы (проверка: scripts/validate_shapes.mjs):

ФигураОбязательные свойства
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 срабатывает на такте ПОСЛЕ коммита, а не синхронно. Если записать фигуру и тут же прочитать состояние в расчёте на отработавший слушатель, он ещё не отработал. Проверено вживую: чтение внутри того же хода показывает ноль срабатываний, а после одного такта 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.
перевод и редактура:

обновлено

⌕ ESC