Однажды я попросил ChatGPT нарисовать организационную диаграмму. Знаете, что он сделал? Написал ASCII-арт:

Мило, но не то.
Я хотел настоящую диаграмму: с прямоугольниками, стрелками, цветами, зумом и возможностью подвигать элементы руками. То есть не картинку в ответе чата и не ASCII, а нормальный интерактивный canvas.
Можно было взять LangChain, MCP, готовый agent framework и спрятать всё за несколькими слоями абстракций. Но мне было интересно другое: что минимально нужно, чтобы LLM стала агентом, который не просто отвечает текстом, а меняет состояние внешнего мира?
В моём случае этим “внешним миром” стал Excalidraw canvas.
Я написал маленький агентный цикл с нуля. Важное уточнение: не весь проект, конечно, а именно ядро агента — около 100 строк. Вокруг него есть сервер, model layer, WebSocket, UI и куча мелких инженерных деталей. Но сама “агентность” живёт именно в этом цикле. Без LangChain, без MCP, без специальных библиотек для “агентности”.
Спойлер: агент не просто нарисовал оргчарт. В одном из прогонов он нарисовал его, потом посмотрел на результат, понял, что стрелки сделаны неправильно, удалил их, пересоздал с правильными привязками и только после этого сказал “готово”.
Вот как это устроено под капотом.
Идея: не просить картинку, а дать инструменты
Обычный чат с LLM работает так:
user prompt -> LLM -> text answer
Агентный цикл добавляет один важный элемент: инструменты.

То есть мы не просим модель “нарисуй мне диаграмму” и не надеемся, что она как-то магически создаст картинку. Мы даём ей функции. Например:
-
get_scene— посмотреть, что уже есть на холсте; -
batch_create_elements— создать пачку элементов сразу; -
update_element— поправить результат; -
done— закончить работу.
Это не полный список, а просто иллюстрация идеи. Полный набор инструментов я покажу ниже.
Модель не рисует сама. Она говорит: “вызови batch_create_elements с такими аргументами”. А уже мой код выполняет этот вызов и меняет состояние canvas.
Ядро агента
В упрощённом виде весь агентный цикл выглядит так:
import { runTool, TOOLS } from './tools.js'export async function agent(prompt: string, sessionId = 'default') { const log: string[] = [] const messages: any[] = [ { role: 'system', content: systemPrompt }, { role: 'user', content: prompt }, ] for (let turn = 0; turn < 20; turn++) { const response = await fetch(LLM_URL, { method: 'POST', headers: { Authorization: `Bearer ${LLM_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ model: LLM_MODEL, messages, tools: TOOLS.map((tool) => ({ type: 'function', function: { name: tool.name, description: tool.description, parameters: tool.parameters, }, })), tool_choice: 'auto', }), }) const data = await response.json() const msg = data.choices[0].message // msg может содержать обычный текст, tool_calls или и то и другое messages.push(msg) if (isDone(msg)) { return { turns: turn, log } } for (const toolCall of msg.tool_calls || []) { const args = JSON.parse(toolCall.function.arguments || '{}') const result = await runTool(toolCall.function.name, args, sessionId) messages.push({ role: 'tool', tool_call_id: toolCall.id, content: JSON.stringify(result), }) } } return { turns: 20, log }}
Это не production-ready версия — в реальном коде ещё есть timeout, проверка HTTP-статуса, обработка ошибок LLM и безопасный парсинг JSON. Но суть именно такая.
Три шага в цикле:
-
Отправить LLM историю сообщений и описание инструментов.
-
Получить ответ модели: обычный текст, tool calls или оба варианта сразу.
-
Выполнить tool calls и вернуть результат обратно в историю сообщений.
После этого модель получает новый контекст и может сделать следующий шаг.
В этом и есть почти вся “магия” агентности.
Как модель узнаёт, какие инструменты доступны
Каждый инструмент описывается примерно как JSON Schema. Например, создание элемента:
{ name: 'create_element', description: 'Create a new Excalidraw element. For arrows, use startElementId/endElementId to bind to shapes.', parameters: { type: 'object', properties: { type: { enum: ['rectangle', 'ellipse', 'diamond', 'arrow', 'text', 'line'] }, x: { type: 'number' }, y: { type: 'number' }, width: { type: 'number' }, height: { type: 'number' }, text: { type: 'string' }, backgroundColor: { type: 'string' }, strokeColor: { type: 'string' }, startElementId: { type: 'string' }, endElementId: { type: 'string' }, }, required: ['type', 'x', 'y'], },}
LLM видит это описание и понимает, какие аргументы можно передать. Например:
{ "type": "rectangle", "x": 320, "y": 80, "width": 160, "height": 70, "text": "CEO", "backgroundColor": "#a5d8ff"}
Модель не обязана знать внутренний формат Excalidraw полностью. Она работает через более удобный слой инструментов, а уже сервер превращает эти параметры в реальные элементы canvas.
Как модель “видит” холст
Важный момент: модель не видит картинку.
Когда я дальше пишу “модель видит” или “модель понимает”, это, конечно, метафора для удобства чтения. На самом деле она не смотрит на canvas глазами. Она получает текстовое описание сцены:
[rectangle] "CEO" at (350, 50)[rectangle] "VP Engineering" at (130, 230)[rectangle] "VP Product" at (570, 230)[arrow] from CEO to VP Engineering[arrow] from CEO to VP Product
Для этого есть инструмент get_scene. Когда модель вызывает его, сервер пробегает по элементам текущей сессии и возвращает краткое описание.
Получается простой feedback loop:
draw -> get_scene -> analyze -> fix -> get_scene -> done
Именно этот шаг превращает генератор JSON в агента. Без обратной связи модель просто один раз “выстреливает” набором элементов. С обратной связью она может проверить, что получилось, и поправить результат.
Почему batch_create_elements важнее, чем кажется
Сначала я думал, что хватит одного create_element. Модель будет создавать элементы по одному: прямоугольник, текст, стрелка, ещё прямоугольник, ещё стрелка.
Но это быстро становится неудобно:
-
много round-trip’ов к LLM;
-
больше шансов ошибиться на середине;
-
сложнее связать элементы между собой;
-
диаграммы создаются медленнее.
Поэтому появился batch_create_elements: модель может одним tool call передать сразу весь набор элементов.
Например:
{ "elements": [ { "id": "ceo", "type": "rectangle", "x": 350, "y": 50, "text": "CEO" }, { "id": "eng", "type": "rectangle", "x": 150, "y": 220, "text": "VP Engineering" }, { "id": "prod", "type": "rectangle", "x": 550, "y": 220, "text": "VP Product" }, { "type": "arrow", "startElementId": "ceo", "endElementId": "eng" }, { "type": "arrow", "startElementId": "ceo", "endElementId": "prod" } ]}
Это сильно повышает качество результата. Модель начинает работать не с отдельными фигурами, а со всей диаграммой целиком: сразу создаёт структуру, связи и относительное расположение элементов.
Самая неприятная деталь: стрелки
Прямоугольник создать просто. Текст создать просто. Даже цвет фона — просто.
А вот стрелки оказались неприятнее.
Если просто создать line от точки A до точки B, визуально она может выглядеть нормально. Но это не настоящая стрелка Excalidraw, привязанная к фигурам. Если потом перетащить прямоугольник, стрелка останется на старом месте.
Мне хотелось, чтобы агент создавал нормальные диаграммы, которые можно редактировать руками. Поэтому стрелки должны быть привязаны к элементам.
Для этого модель передаёт не координаты начала и конца, а startElementId и endElementId:
{ "type": "arrow", "startElementId": "ceo", "endElementId": "vp-eng"}
А сервер уже вычисляет точки на границах фигур и заполняет Excalidraw binding:
function edgePoint(el, targetX, targetY, gap) { const cx = el.x + el.width / 2 const cy = el.y + el.height / 2 const dx = targetX - cx const dy = targetY - cy const hw = el.width / 2 const hh = el.height / 2 const scale = Math.abs(dx) * hh > Math.abs(dy) * hw ? hw / Math.abs(dx): hh / Math.abs(dy) return { x: cx + dx * scale + (dx > 0 ? gap : -gap), y: cy + dy * scale + (dy > 0 ? gap : -gap), }}
Для прямоугольника это пересечение линии “центр фигуры -> цель” с границей прямоугольника. Для ellipse и diamond формулы немного другие; в примере выше я опустил эти ветки ради краткости. Идея та же: не заставлять модель считать геометрию, а спрятать это в инструменте.
Это важный принцип: хороший tool должен быть удобен для модели, а не для внутреннего формата библиотеки.
Что агент умеет
В текущей версии у агента около двух десятков инструментов. Он может:
-
создавать элементы;
-
создавать элементы пачкой;
-
читать сцену;
-
обновлять элементы;
-
удалять элементы;
-
группировать и разгруппировывать;
-
выравнивать и распределять;
-
блокировать элементы;
-
делать snapshot сцены;
-
восстанавливать snapshot;
-
управлять viewport;
-
читать краткий design guide;
-
завершать работу через
done.
Но на практике чаще всего используются 4-5 инструментов:
-
get_scene— посмотреть, что уже есть на холсте; -
batch_create_elements— нарисовать основную диаграмму; -
create_element— добавить отдельный элемент или стрелку; -
update_element— поправить результат; -
done— закончить.
Это тоже хороший урок: модели не нужен огромный API. Ей нужен маленький набор хорошо описанных действий.
Агент, который сам себя чинит
Самый интересный момент случился на тесте с трёхуровневым оргчартом.
Я попросил агента нарисовать структуру компании. В этом прогоне сигналом стала текстовая сцена: после get_scene модель увидела, что стрелки существуют как отдельные элементы, но не выглядят как привязанные связи между фигурами через startElementId/endElementId. Поэтому она решила удалить их и создать заново уже как bound arrows.
Лог выглядел примерно так:
🤖 Let me check the current scene 🛠 get_scene({})🤖 Canvas is empty. Creating org chart... 🛠 batch_create_elements({ 6 rectangles, 5 arrows })🤖 The arrows are wrong — they're not bound to shapes. Let me fix that. 🛠 delete_element({ id: "arrow-1" }) 🛠 delete_element({ id: "arrow-2" }) 🛠 delete_element({ id: "arrow-3" }) 🛠 delete_element({ id: "arrow-4" }) 🛠 delete_element({ id: "arrow-5" })🤖 Recreating arrows with proper bindings 🛠 create_element({ type: "arrow", startElementId: "ceo", endElementId: "vp-eng" }) 🛠 create_element({ type: "arrow", startElementId: "ceo", endElementId: "vp-prod" }) 🛠 create_element({ type: "arrow", ... }) 🛠 create_element({ type: "arrow", ... }) 🛠 create_element({ type: "arrow", ... })🤖 Let me verify the result 🛠 get_scene({})🤖 Done! Here's your org chart.
Конечно, это не значит, что модель всегда будет так делать. Это не формальная гарантия и не магия.
Но это показывает важную вещь: если дать модели инструменты, результат выполнения и возможность проверить состояние, она иногда начинает вести себя как простой автономный исполнитель:
посмотреть -> сделать -> проверить -> исправить -> завершить
Мне кажется, именно здесь проходит граница между “LLM с функциями” и “агентом”. Не в количестве фреймворков, а в наличии цикла обратной связи.
Где начались настоящие грабли
Ядро агента маленькое. Но это не значит, что весь проект простой.
Сложность просто переезжает из agent loop в другие места.
1. Формат сцены важнее, чем кажется
Модель не видит canvas. Она видит только то, что возвращает get_scene. Если описание слишком бедное, модель не сможет понять, что не так. Если слишком длинное — контекст быстро засоряется.
Нужно подобрать формат, который достаточно информативен, но не превращается в dump всего JSON Excalidraw.
2. Tool descriptions — это часть UX
Инструменты нужны не только коду. Они нужны модели.
Плохое описание инструмента — это как плохой UI для человека. Модель будет вызывать функцию неправильно, путать аргументы, забывать важные поля.
Например, для стрелок пришлось явно писать: “use startElementId/endElementId to bind to shapes”. Без этой подсказки модель часто пыталась рисовать стрелки координатами.
3. Batch operations почти обязательны
Если агент рисует большую диаграмму по одному элементу, он тратит много итераций. Поэтому batch_create_elements оказался одним из самых важных инструментов.
4. Настоящее состояние должно жить на сервере
Если canvas открыт в браузере, агент работает на сервере, а элементы лежат только во frontend state — всё быстро ломается.
Поэтому в проекте есть простая model layer: сессии, элементы, snapshots и WebSocket-уведомления. Это уже не “агент за 100 строк”, но без этого агенту не с чем работать.
5. Без лимитов публичный demo опасен
Если endpoint /api/agent доступен наружу, любой человек может начать тратить ваш LLM API key. Не буду глубоко уходить в безопасность — это тема для отдельной статьи, — но минимальный чек-лист такой:
-
token guard;
-
rate limit;
-
ограничение длины prompt;
-
timeout на LLM-запрос;
-
аккуратная обработка ошибок.
Это скучная часть, но без неё такой проект нельзя спокойно выкладывать на VPS.
Бонус: что если дать агенту посмотреть на самого себя
Я попробовал добавить в system prompt фразу: “Ты — AI-агент, который рисует диаграммы. Твой код на TypeScript лежит в src/agent.ts”.
Ответ получился забавный. Агент ответил по-английски; привожу смысл:
Я работаю как AI-агент в цикле. У меня есть набор инструментов, и я вызываю функции, чтобы рисовать диаграммы. Мой код лежит в
agent.ts: я мог бы прочитать его и рассказать, как устроен, но лучше просто нарисую диаграмму.
Он не стал философствовать о себе, а продолжил решать задачу.
В каком-то смысле это правильное поведение. Хороший агент не обязан размышлять о своей природе. Он должен двигать состояние к цели.
Что в сухом остатке
Этот проект на самом деле не про Excalidraw.
Excalidraw — просто удобная песочница, где результат действий агента сразу виден глазами. Можно было бы использовать тот же подход для GitHub issues, файловой системы, CI/CD, CRM, календаря или чего угодно ещё.
Главные выводы для меня такие.
1. Агентный цикл проще, чем кажется
В минимальном виде это:

Никакой обязательной магии внутри нет.
2. Инструменты важнее фреймворка
LangChain, MCP и другие инструменты могут быть полезны. Но агентность появляется не из-за них. Она появляется, когда у модели есть понятные действия и обратная связь.
3. Модель может исправлять ошибки, если видит результат
Не всегда. Не идеально. Но может.
Если агент может посмотреть на состояние после своих действий, у него появляется шанс заметить проблему и сделать следующую итерацию лучше.
4. “100 строк” — это только ядро
Да, agent loop действительно маленький. Но весь проект больше: model layer, tools, WebSocket, UI, формат Excalidraw-элементов, привязка стрелок, deploy и ограничения безопасности.
И это нормально. Агентный цикл — маленький. Полезная среда вокруг него — нет.
Код
Проект открыт здесь:
https://github.com/mikhail-angelov/excalidraw-agent
Он основан на mcp_excalidraw от yctimlin: оттуда взяты идеи вокруг canvas, UI и формата элементов. Агентный цикл я написал с нуля, чтобы разобраться, что происходит под капотом.
ссылка на оригинал статьи https://habr.com/ru/articles/1056842/