Платформа
Решения
Кто использует Directual и почему?
Что можно сделать на платформе?
Какие отрасли подходят для Directual?
Материалы
Агентный цикл «модель → инструменты → модель» неудобно собирать из шагов сценария: ретраи, стриминг, лимиты и подсчёт токенов быстро превращают его в кашу. Разбираем схему, в которой цикл крутит theloop — открытый stateless HTTP-сервис, — а Directual остаётся оркестратором: хранит историю, данные, права, подтверждения и биллинг.

В статье про 10 мифов об ИИ-агентах мы писали, что LLM — исполнитель, и чтобы он работал надёжно, его нужно встроить в систему. А в материале «ИИ-агенты: не тренд, а необходимость» перечисляли, какая инфраструктура для этого нужна. Сегодня покажем, как это сделать на практике: агент ходит в ваши данные, предлагает действия и ждёт подтверждения пользователя. При этом сам агентный цикл в Directual мы собирать не будем.
Схема не теоретическая: в таком виде она уже работает в продакшене у одного из наших клиентов.
Чат-бот отправляет модели вопрос и показывает ответ — так устроено простое подключение ChatGPT к Directual. Агент устроен сложнее: модель может попросить вызвать инструмент («найди заказ 12345»), получить результат, попросить ещё один и только потом ответить. Это и есть цикл: модель → инструменты → модель → … → финальный ответ.
Собрать такой цикл в сценарии Directual технически можно: HTTP request к провайдеру, условие «есть ли tool_calls», ещё один HTTP request, переход назад. На демо это даже работает. В продакшене вылезает всё остальное:
Всё это работа не для оркестратора, а для движка. Мы вынесли её в отдельный open-source сервис theloop.
theloop — агентный цикл в виде stateless HTTP-сервиса (Apache 2.0, Node 22). Один запрос POST /runs — один прогон. В запросе приходит всё: провайдер, модель, ключ, системный промпт, полная история диалога, инструменты (URL + JSON-схема) и лимиты. Движок крутит цикл до финального ответа, стримит текст по SSE, а результат и точный расход токенов отправляет в callback. Между вызовами он ничего не помнит.
Поэтому он хорошо стыкуется с Directual. Всё, что требует хранения, остаётся у вас: история, ассистенты, промпты, ключи, операции, подтверждения, деньги. В терминах theloop Directual — это harness, то есть оркестратор. Правило разделения простое:
Всё, что решается хранением данных или вызовом эндпоинта, — в Directual. Всё, что требует цикла, стрима или таймера, — в theloop.
Провайдеры из коробки: Anthropic, OpenAI, DeepSeek, GLM, GigaChat, MiniMax, Kimi и любой OpenAI-совместимый эндпоинт. Можно передать цепочку фоллбэков: если основная модель упала до начала ответа, движок перейдёт к следующей.
Фронтенд ──пишет──► raw_chat_message
▲ │
│ сокет: ▼
│ stream_url ┌──────────────── Directual ────────────────┐
│ │ S1 «Старт прогона» │
│ │ собрать историю, промпт, инструменты │
│ │ HTTP request: POST /runs ───────────────┼──► theloop
│ │ │ │
│ │ API endpoint'ы инструментов ◄─────────────┼───────┤ вызовы tools
│ │ read: отдать данные │ │
│ │ write: зарегистрировать операцию │ │
│ │ │ │
│ │ API endpoint callback ◄───────────────────┼───────┘ результат
│ │ записать сообщения, usage, статус │
│ └───────────────────────────────────────────┘
│
└──── SSE по stream_url (текст по мере генерации) ──── theloop
Directual в цикл не лезет: он запускает прогон, отвечает на вызовы инструментов как обычный API и принимает результат.
Нужен один всегда включённый контейнер с публичным HTTPS-адресом:
docker run -p 8080:8080 \
-e ENGINE_TOKEN=длинный-случайный-токен \
-e CALLBACK_SECRET=другой-случайный-секрет \
-e PUBLIC_URL=https://loop.example.com \
-e EGRESS_ALLOWLIST=api.directual.com \
-e ALLOWED_ORIGINS=https://app.example.com \
-e ENGINE_LOCALE=ru \
-e DEFAULT_TIMEZONE=Europe/Moscow \
ghcr.io/paveler/theloop:1
Что здесь важно:
ENGINE_TOKEN — им Directual авторизуется в движке. Храните его в секретах приложения и никогда не отдавайте на фронтенд: браузер ходит в движок по одноразовым токенам прогона.EGRESS_ALLOWLIST — движок ходит только на разрешённые хосты, только по HTTPS, без редиректов и приватных IP. Инструменты и callback живут в API Directual, поэтому разрешаем api.directual.com.CALLBACK_SECRET — движок кладёт его в заголовок X-Engine-Secret каждого callback'а. По нему Directual отличает настоящий результат от подделки.ENGINE_LOCALE и DEFAULT_TIMEZONE — по умолчанию движок пишет служебные строки по-английски и считает время в UTC. Для русскоязычного продукта задайте оба, иначе модель увидит дату не в том поясе, а пользователь получит английские ошибки.GET /health показывает, сколько прогонов идёт прямо сейчас.Ключи провайдеров можно задать в env (ANTHROPIC_API_KEY=…) или передавать в каждом запросе из Directual. Второй вариант удобен, если у клиентов свои ключи.
Справочники — то, что настраивает ассистента:
| Структура | Ключевые поля |
|---|---|
assistant |
provider, model, api_key (секрет), system_prompt, params, limits, pricing, tools (links) |
tool |
id (имя для модели), description, input_schema (json), endpoint, headers (секрет), effect (read / write), enabled |
Это, пожалуй, главный бонус связки. Модель, промпт и набор инструментов — данные, а не код. Хотите сравнить Claude и GigaChat на том же диалоге? Поменяйте два поля в записи assistant, передеплой не нужен. Новый инструмент — новая запись в tool плюс эндпоинт.
Рабочие структуры — то, что живёт в диалоге:
| Структура | Ключевые поля | Зачем |
|---|---|---|
raw_chat_message |
session, text, ответные stream_url, cancel_url, error |
сюда пишет фронтенд; это намерение пользователя, а не история |
chat_session |
state (idle / running / awaiting_tool), active_run, history_revision |
диалог и его состояние |
chat_message |
id, session, payload (json), role, seq, created_at |
история в формате движка |
run |
id = run_id, status, deadline_at, result (json), usage-поля, cost |
результаты прогонов и биллинг |
Ключевое поле — chat_message.payload. В нём лежит сообщение в формате theloop (StoredMessage) целиком и без изменений: реплики пользователя, ответы модели, вызовы инструментов и их результаты, reasoning. При каждом прогоне эти payload'ы по порядку уходят в движок как messages. Не пытайтесь собирать историю из того, что видно в интерфейсе: без результатов инструментов и reasoning модель на следующем шаге теряет контекст, а часть провайдеров вообще откажется продолжать.
Остальные поля chat_message (role, превью текста, call_id) — денормализация для интерфейса и выборок.
Триггер — новый объект в raw_chat_message.
1. Проверить состояние сессии. Если state = running, пользователь пишет, пока агент ещё отвечает. Делаем то же, что делает человек, которого перебили: POST /runs/{id}/cancel. Отмена синхронная, движок сразу возвращает результат с уже сгенерированным текстом. Записываем его так же, как результат в callback'е (шаг 5), отправляем POST /runs/{id}/ack и идём дальше. Два прогона на одну сессию одновременно запускать нельзя: второй получит историю без ответа первого.
2. Записать сообщение пользователя в chat_message. В качестве id берём id raw_chat_message:
{ "role": "user",
"content": [ { "type": "text", "text": "{{text}}" } ],
"created_at": "{{date_created}}" }
history_revision увеличиваем на 1. created_at обязателен: по нему движок показывает модели дату каждого сообщения, одну и ту же на любом ходе. Тогда история между ходами только дописывается и prompt cache провайдера продолжает работать.
3. Собрать тело запроса. Удобнее всего это сделать JS-шагом (если с JSON в сценариях вы ещё не работали, начните с урока про JSON и связи), который соберёт system, массив messages (все payload сессии, отсортированные по created_at, seq) и массив tools из записей справочника:
{
"contract_version": "1",
"idempotency_key": "{{raw_chat_message.id}}",
"history_revision": {{chat_session.history_revision}},
"llm": {
"provider": "{{assistant.provider}}",
"model": "{{assistant.model}}",
"api_key": {{assistant.api_key | null}},
"params": {{assistant.params}},
"pricing": {{assistant.pricing | null}}
},
"system": "{{system}}",
"messages": {{messages}},
"tools": {{tools}},
"limits": {{assistant.limits}},
"callback": { "url": "<URL эндпоинта callback, шаг 5>" },
"meta": {
"session_id": "{{chat_session.id}}",
"user_id": "{{user.id}}",
"tenant_id": "{{tenant.id}}",
"assistant_id": "{{assistant.id}}",
"timezone": "Europe/Moscow"
}
}
Пара практических мелочей. Пустые поля отправляйте как null, а не пустой строкой: "api_key": "" не пройдёт валидацию. Массивы в JS-шаге собирайте циклом с push, а не concat. Дату и время в промпт добавлять не нужно: движок допишет их сам по meta.timezone.
Системный промпт тоже можно собирать из данных. Например, ядро промпта плюс разделы, которые зависят от состояния диалога: есть ли у пользователя заполненный профиль, открыт ли чат из карточки конкретного объекта. Тем же условием режется и набор инструментов. Модель в этом выборе не участвует, всё решает обычная логика сценария.
4. HTTP request: POST {ENGINE_URL}/runs с заголовком Authorization: Bearer {ENGINE_TOKEN}.
5. Разобрать ответ:
202 — прогон запущен. Создаём run с id = run_id, status = running и deadline_at = now + timeout_ms + 120 с. Ставим сессии state = running, а в raw_chat_message пишем stream_url и cancel_url. Фронтенд подхватывает их по сокету и подписывается на стрим;200 — повтор того же idempotency_key: прогон уже идёт, второй не запустился. Делаем то же самое;409 result_pending — результат предыдущего прогона ещё не записан. Забираем его через GET /runs/{id} (шаг 7), записываем, отправляем ack и повторяем запрос;429 или 503 — движок перегружен или перезапускается. Пауза пару секунд и повтор;400 — ошибка в собранном теле, в ответе написано, какая именно.Дальше сценарий заканчивается. Ждать окончания прогона не нужно: результат придёт в callback.
Инструмент в theloop — это URL и JSON-схема аргументов. Когда модель просит инструмент, движок отправляет на этот URL POST. В Directual это обычный API endpoint с синхронным сценарием, а URL и заголовок авторизации лежат в записи tool.
Тело, которое приходит на эндпоинт:
{ "contract_version": "1", "run_id": "run_…", "call_id": "call_…", "attempt": 1,
"origin_request_id": "…", "tool": "getOrders",
"args": { "orderId": "12345" },
"meta": { "session_id": "…", "user_id": "…", "tenant_id": "…" },
"result_url": "…" }
Сценарий делает выборку и отвечает. Формат ответа задаётся в API response эндпоинта:
{ "status": "ok", "result": { … } } — результат уходит модели;{ "status": "error", "error": "заказ не найден" } — модель увидит ошибку и переформулирует запрос, прогон при этом не падает.Главное правило безопасности: фильтруйте данные по meta, а не по args. В args модель может подставить что угодно, в том числе чужой user_id, если её уговорили в промпте. meta модель не видит и подделать не может: его кладёт Directual при запуске прогона. Поэтому сценарий ищет заказы с условием user = meta.user_id, а orderId из args использует только как фильтр внутри этой выборки.
Ещё три совета:
Authorization или X-*. Из соображений безопасности движок пропускает к инструментам только их. Заголовок с другим именем (Api-Key, Token) он молча выкинет, и эндпоинт ответит 401.tool_result_max_bytes, а каждый лишний байт вы оплачиваете токенами на каждом шаге цикла.Самое интересное — инструменты, которые что-то меняют: оформить возврат, отменить заказ, списать деньги. В theloop у них "effect": "write", и они работают по особому правилу:
Write-эндпоинт ничего не меняет. Он регистрирует намерение — создаёт операцию — и всегда отвечает
{ "status": "deferred", "operation_id": "…" }.
В Directual это тот же API endpoint с синхронным сценарием, а нужный формат задаётся в API response: { "status": "deferred", "operation_id": "{{operation.id}}" }.
Получив deferred, движок завершает прогон со статусом awaiting_tool и забывает о нём. Дальше всё происходит в Directual:
createReturn создаёт объект в operation со статусом pending и отвечает deferred.chat_message (id = call_id) и запускает новый прогон, уже без нового сообщения пользователя:{ "role": "tool", "call_id": "call_…", "tool": "createReturn",
"operation_id": "op_…", "result": { "return_id": "R-881" }, "is_error": false }
При отказе пишем "is_error": true, "error": "отклонено пользователем", и модель сама объяснит пользователю, что дальше.
Почему так, а не «пусть инструмент сразу делает возврат»? Причин три:
deferred подходит для всего, что занимает часы: согласование у менеджера, ответ банка, генерация документа. Движок не держит соединение, а Directual умеет ждать сколько угодно.Чтобы повторный вызов не создал вторую операцию, её id должен быть детерминированным: op_ + sha256 от meta.session_id, имени инструмента и origin_request_id (это id сообщения пользователя). sha256 считается прямо в сценарии. Повторный вызов с теми же данными попадёт в тот же объект. Перед выполнением проверяйте версию операции: модель могла успеть поменять сумму следующим вызовом, и пользователь не должен подтвердить то, чего не видел. При исполнении передавайте operation.id во внешнюю систему (банк, платёжку) как её ключ идемпотентности. Полный алгоритм, включая поиск уже существующей операции, описан в спецификации, §6.4.
Если пользователь вместо нажатия на карточку пишет что-то новое, закройте висящий вызов синтетическим результатом "error": "superseded" и запустите прогон с новым сообщением. Сама операция при этом остаётся, и её карточку можно подтвердить позже.
Когда прогон закончился, движок отправляет POST на callback.url. Тело приходит в конверте:
{ "payload": { "run_id": "run_…", "status": "completed", "messages": [ … ],
"usage": { … }, "tool_calls": [ … ], "meta": { … }, … } }
Конверт с одним полем сделан специально под эндпоинты Directual. Эндпоинт принимает только поля из своего списка на запись. Если бы тело было плоским, вы получили бы … is not allowed for write на первом же неизвестном поле, а массив messages в json-поле упал бы с unsupported conversion from array to json. С конвертом эндпоинту нужно одно поле на запись — payload (json), и новые поля результата его не сломают.
Синхронный сценарий эндпоинта:
X-Engine-Secret с константой и отклоняет запрос, если не совпало. Секрет не кладите в query URL callback'а: query оседают в логах прокси.run по payload.run_id. Callback доставляется at-least-once, дубли будут. Сам эндпоинт id записи не задаёт, поэтому дедупликацией занимается сценарий: ищет run по run_id и обновляет его, а не создаёт новый. В run пишет result, status, usage-поля и cost.payload.messages — upsert chat_message с id = <run_id>:<seq> и payload как есть. Это только новые сообщения этого прогона, старые движок никогда не возвращает. Детерминированный id делает повторную доставку безопасной: дубль перезапишет те же объекты.completed, stopped_limit, cancelled и failed переводят её в idle, awaiting_tool — в awaiting_tool со списком висящих call_id. history_revision увеличивается на 1.Перед подключением движка проверьте эндпоинт руками: отправьте curl'ом реальное тело результата дважды. После второго раза в chat_message не должно появиться дублей.
Про stopped_limit: если модель упёрлась в max_steps, движок даёт ей последний шаг без инструментов и всё равно возвращает текстовый ответ. Это не ошибка, показывайте его как обычный ответ. При failed в error.message уже лежит понятный пользователю текст на языке из ENGINE_LOCALE. Покажите его как системное сообщение, в историю его писать не нужно.
Фронтенд по сокету видит stream_url в своём raw_chat_message и подписывается на стрим напрямую в движке. Прокси через Directual не нужен: в ссылке уже есть токен, действующий только для этого прогона.
const es = new EventSource(msg.stream_url)
es.addEventListener('delta', e => appendText(JSON.parse(e.data).text))
es.addEventListener('tool_call', () => showStatus('Смотрю данные…'))
es.addEventListener('done', () => es.close())
По умолчанию стрим отдаёт только текст и обезличенный прогресс: ни аргументов, ни результатов инструментов. В результатах могут быть данные, которые модели видеть нужно, а пользователю целиком — нет.
Кнопка «Стоп» — это POST cancel_url прямо из браузера. Directual ничего специально делать не должен: результат отменённого прогона придёт обычным callback'ом.
Тонкий момент: событие done в браузере ещё не значит, что история записана. Записанные сообщения появятся в chat_message после callback'а, а стрим — только предпросмотр.
theloop держит прогоны в памяти. Если контейнер упадёт, прогон пропадёт и callback не придёт. Поэтому нужен сценарий по расписанию, раз в минуту:
run со status = running и deadline_at < now;GET /runs/{id}. Если пришёл готовый результат, записать его так же, как в шаге 5, и отправить POST /runs/{id}/ack. Если прогон ещё идёт, продлить дедлайн;404, прогон потерян: run.status = lost, сессию в idle, пользователю сообщение «не удалось получить ответ, повторите».lost не окончательный статус. Если поздний callback всё-таки придёт, примите его: сравните history_revision из результата с текущей. Если ревизии совпадают, записывайте как обычно. Если уже был следующий ход, сохраните результат как аудит, но в историю не вставляйте.
| Directual | theloop |
|---|---|
| интерфейс, пользователи, права | агентный цикл |
| история диалога и её порядок | стриминг и отмена |
| ассистенты, промпты, модели и ключи как данные | ретраи провайдеров, фоллбэки, rate limiting |
| данные и инструменты как API endpoint'ы | вызов инструментов, валидация аргументов, защита от зацикливания |
| операции, подтверждения, исполнение действий | deferred вместо мутаций |
биллинг клиентов по usage |
точный подсчёт токенов по каждой попытке |
Каждый занимается своим. Directual не нужно учить ретраям провайдеров и SSE, а движку — знать, кто ваши клиенты и что им можно. Мультитенантность получается бесплатно: в движке нет состояния тенантов, всё приходит в запросе.
Чем theloop пока не является: это не конструктор агентов, не RAG и не мультиагентная система. Один инстанс, прогоны в памяти. Нужен поиск по базе знаний — оформите его как read-инструмент: эндпоинт в Directual, который ищет и возвращает фрагменты.
"wait": true. Тогда ответ придёт сразу в теле запроса — ровно то же тело потом будет приходить в callback.Полный контракт — в SPEC.md. Особенно полезно приложение A: чек-лист обязательств оркестратора. Большинство проблем в продакшене возникает из-за пропуска одного из этих пунктов. Пример оркестратора на SQLite лежит в examples/sqlite-harness: логика та же, только вместо структур Directual — таблицы.
Вопросы, баги и истории о том, как вы собрали агента на Directual, присылайте в issues на GitHub.
Если вы только подступаетесь к агентам, пройдите наш бесплатный курс по ИИ-агентам: за час он даёт базу, на которую потом ложится эта схема. Что ещё Directual умеет для агентных продуктов, собрано на странице ИИ-агенты на Directual.