HTTP/SSE API
Справочник по API — Swagger UI на /apidocs
(схема: /v1/openapi.json).
Схема OpenAPI генерируется из аннотаций прямо в коде (src/api.rs,
utoipa) — руками не пишется и не расходится с реальностью.
Локально: подними ядро и открой http://127.0.0.1:8090/apidocs.
Пара вещей, которых не расскажет схема:
- Ответы чатов — SSE (
text/event-stream), по JSON-объекту на событие:text_delta,thinking_delta,tool_call,tool_result,tool_progress,client_op,extra_tool_call,request_resolved,usage,compacting,turn_squeezed,sleeping,image,plan,done,error— плюс зеркало этих же шагов единой лентой элементов:item_started,item_delta,item_completed(см. ниже). На переходный период едут оба набора; ненужный глушится полемmuteвsend_message(списком типов событий) или query-параметромmuteу/live(через запятую).usageприходит после каждого похода к провайдеру — по нему клиент двигает счётчик токенов и расхода, не дожидаясь конца ответа; егоreasoning_tokens— частьcompletion_tokens, ушедшая в рассуждения, а не добавка к ним; долю занятого окна клиент считает отprompt_tokensи потолка модели изcontext_windows(GET /v1/models), вычитая базу —prefix_tokensизGET /v1/chats/{id}/spend. Последний снимок лимитов провайдера отдаёт/v1/statsполемrate_limits;compactingпредупреждает, что ядро сворачивает историю в резюме: пауза передdoneбывает долгой и без отметки выглядит зависанием;turn_squeezed({"freed_chars":N}) — ядро поджало трафик ИДУЩЕГО тура: длинный ход перерос долю порога компакции, и его старый выхлоп схлопнут прямо посреди работы. Ответ продолжается — событие показывают тихо;sleepingоткрывает поток тура, пришедшегося на сон: тур ждёт пробуждения в очереди — клиент показывает «спит…» вместо «печатает…» и кнопку «Разбудить» (POST /v1/sleep/wake), дальше поток идёт как обычно. image— модель тура сгенерировала картинку, ядро сохранило её:{"type":"image","id":"<uuid>","media_type":"image/png","url":"…/images/<id>"}. Клиент рисует картинку поurl; в истории на её месте остаётся текстовый маркер[image <id>] <url>. Байты отдаётGET /images/{id}— ручка вне/v1и потому вне токена: ключ доступа — неугадываемый uuid, ответ неизменен (Cache-Control: immutable).plan— план работы изменился:tasks— состояние целиком, а не дифф, клиент рисует дерево не собирая его из правок. Пустой список — план закрыт (всё сделано), блок пора убрать. В историю не пишется: живое состояние достаётсяGET /v1/chats/{id}/plan, стираетсяDELETEом по тому же пути.- Свёртка истории оставляет след и в самой истории: после компакции ядро
дописывает служебное сообщение-маркер —
role: user, единственный текстовый блок, байт-в-байт"[compacted] Older history was folded into the chat summary here.". Клиент опознаёт маркер точным совпадением текста и рисует границу компакции даже после перезагрузки, а не только по живомуcompacting; рисовать его репликой Господина нельзя (признакmasterу него, как у любого сообщения безsender, истинный). В промпт модели маркер не попадает. В единой ленте элементов маркер отдаётся элементомcompaction, неmachine_note: клиенту ленты текст не нужен, граница — сама по себе вид шага. - Единая лента элементов — одна модель для живого тура, реплея
/liveи чтения истории, чтобы клиент не сшивал/messagesи SSE в свою третью. Элемент несётid,kind,status(running/completed/failed/aborted),turn_id(группировка шагов по турам) и тело по виду:user_message,agent_message,thinking,tool_call(результат вложен, отдельного элемента нет),client_op,image,compaction(виден и в истории, не только в живом туре),machine_note(служебные вставки ядра,note— id вида из реестра фрагментов),plan. Новый вид шага доезжает во все клиенты одной правкой ядра плюсcaseна клиента.- Жизненный цикл на проводе:
item_started(полный элемент, уже рисуемый) → сколько-то типизированныхitem_delta(текст дописывается, прогресс замещается, план приходит целиком) →item_completed— авторитетное финальное состояние. started оптимистичен, completed авторитетен: клиент рисует сразу и правит потом. GET /v1/chats/{id}/items— история той же лентой, пагинация как у/messages(limit,before— id элемента).limitсчитает сообщения-источники, а не элементы: одно сообщение разбирается на несколько элементов или ни одного, так что страница привозит их произвольное число, и «элементов меньшеlimit» началом истории НЕ является — листать до пустой страницы. Id исторических элементов детерминированы:{message_id}:{n}; у живых —{turn_id}:{n}. Послеdoneклиент перечитывает хвост/itemsи заменяет элементы тура; вызовы сшиваются поcall_id, картинки — поimage_id.- Реплика пользователя в живой ленте не едет (свою клиент знает сам,
переподключившийся берёт её из
/items);client_op-элементы живут только в живом стриме и закрываются своимrequest_resolved(resolutionв теле) — иногда уже послеdone.
- Жизненный цикл на проводе:
GET /v1/chats/{id}/live— переподключение к идущему туру: реплей накопленного + живой хвост. События из реплея несутreplayed: true(живой хвост идёт без признака) — контракт: реплей рисует, но не исполняет. Клиент обязан не исполнятьclient_opс этим признаком, чем бы ни кончилась операция; своих эвристик «моложе подключения» не нужно. Операция из реплея без парногоrequest_resolvedещё ждёт ответа — она истечёт своим таймаутом и уедет модели ошибкой, это штатный исход переподключения.- Уровень рассуждения (
off|low|mid|high) выбирается по убыванию силы: полеeffortвsend_message(на один ход) → словоultrathinkв реплике Господина → настройка чата (PUT /v1/chats/{id}/effort) → полеeffortзаписи модели (GET /v1/modelsотдаёт их вefforts) → глобальныйllm.reasoning(он жеeffort_defaultв каталоге). Внутри тура уровень может подняться ещё раз — инструментомthink_harder; понизить его до конца хода нельзя. DELETE /v1/chats/{id}/messages/{message_id}отматывает чат к состоянию до сообщения: удаляет его и всё после. «Переписать» у клиента — это тот же вызов плюс обычная отправка нового текста. Разрушающе и невозвратно — действием по умолчанию клиенту стоит делать ветку (ниже), а стирание оставить вторым пунктом с подтверждением.POST /v1/chats/{id}/fork({before_message, title?}) — неразрушающая правка прошлого: новый чат с копией истории строго доbefore_message, мать не меняется ни на строку. «Переписать в ветке» — форк плюс отправка исправленного текста в ветку. Ветка наследует полку, проект, модель, уровень рассуждения, набор инструментов, профиль компакции, рабочий каталог, резюме компакции (если его якорь попал в копию), блокнот и план; срез посреди тура (tool_use без результата в копии) закрывается той же синтетикой, что у отмены тура, — ветка едет с первого запроса. Связь — в полях чатаforked_from/forked_at_message; первым сообщением ветки лежит служебный маркер[forked] …(в ленте элементов —machine_noteс notefork_marker, в промпт модели не едет). Ветка — обычный чат: форк от форка разрешён, удаление матери ветку не трогает; расход считается своим чатом. Автоматически ядро веток не плодит — форк только жестом Господина.- Действия
client_op:read,write,shell— текстовые, и байтовыеread_bytes(path,offset,size→{"total", "data"}, данные в base64),write_bytes(path,offset,data,truncate→{"total"}) иfind_bytes(path,data— образец,offset— откуда,limit→{"total", "offsets"}). Клиент, который байтовых не умеет, отвечает ошибкой «unknown op action» — договариваться о версиях не нужно. - Служебное действие
list_up(path— каталог,names— имена): клиент идёт отpathвверх до корня ФС и на каждом уровне говорит, какие из имён существуют — ответ{"dirs": [{"dir": "...", "found": ["..."]}]}, уровни отpathвверх. Им ядро собирает файлы проекта (AGENTS.md,.eva/MEMORY.md) в блок[project]на входе в тур, без участия модели, и разрешения оно не заслуживает — только чтение имён. Прилетает лишь тем, кто объявилproject_docs: trueвsend_message; объявивший перестаёт клеитьAGENTS.mdвcontextсам — иначе файлы задвоятся. - Рабочий каталог чата умеет ставить не только модель тулом
workdir, но и сам клиент: поляworkdir/workdir_on(client|server, дефолтclient) вPOST /v1/chatsиPUT /v1/chats/{id}/workdir(nullснимает); читается он полемworkdirв GET чата. Валидация общая с тулом. Так кодовая сессия получает[project]с первого же тура, не тратя итерацию инструментов на «выставь workdir». - Живой вывод долгой операции (
shellу клиента): покаclient_opждёт результата, клиент может читать вывод потоком и постить снимки хвоста вPOST /v1/chats/{id}/ops/progress({id, output}). Снимок ЗАМЕЩАЕТ прошлый (семантикаtool_progress, в который он превращается), режется по границе UTF-8 и ужимается ядром до последних 8 КиБ; секреты затираются на входе, в историю ничего не пишется. Постить разумно — не чаще нескольких снимков в секунду; финальный полный вывод по-прежнему едет вops/result. Для операций подагентов прогресс молча глушится: в стриме тимлида у них нет своего элемента вызова. - У
client_opбывает полеagent: операцию просит подагент, а исполняет её клиент тимлида. Показывать имя обязательно — иначе непонятно, чью правку разрешает Господин. Результат постится как обычно: операция ищется поop_id, чат в пути не важен. - Тулы на один тур: поле
extra_toolsвsend_message({name, description, input_schema?, timeout_secs?, ends_turn?}) объявляет тулы поверх реестра ядра — модель видит их как обычные, а исполняет сам объявивший клиент. Вызов приходит в стрим событиемextra_tool_call {id, name, input}(id совпадает с idtool_call-события этого вызова), ядро стоит и ждёт; ответ клиент постит вops/resultпод этим id — лучше полемblocks([{title, content}]): ядро отрендерит секции канонично (== title), тем же форматом, что у батчевыхread_fileиshell.ends_turn: trueделает тул вердиктом — успешный вызов закрывает тур (так живёт телеграмныйtelegram_skip). Имя, тенящее тул реестра, — 400. Контракт реплея тот же, что уclient_op: реплей рисуют, но не исполняют. SDK: в Rust —Turn::extra_tool::<T>()+ событиеExtraToolCall {call, response}с одноразовым респондером (брошенный без ответа сам шлёт ядру ошибку), в Python —Turn.extra_tools([ExtraTool(...)])+Kernel.tool_result. - Запрос ядра к клиенту (
client_op,extra_tool_call, вопросask_questions) — сущность со снятием: любой исход уезжает в живой стрим событиемrequest_resolved{id, kind: "op"|"question", reason, question?}.reason:answered— ответ получен (приходит всегда, включая ответ самого клиента),expired— срок вышел,turn_ended— тур кончился, не дождавшись,cancelled— тур отменили. Клиент по нему закрывает диалог и вычищает свою очередь одобрений — не гадая таймером «чуть короче ядрового». У операцииid— еёop_id; у вопросаid— id tool_call-вызоваask_questions, аquestion— номер вопроса в его массиве. - Набор кнопок задаёт ядро, а не клиент:
client_opнесётdecisions(сегодняallow,allow_session,allow_always,deny; у служебногоlist_upполя нет — диалог ему не рисуют), в input tool_call-событияask_questionsвклеивается словарь мета-решенийdecisions(discuss,answer_in_text) поверх модельныхquestions— и в живое событие, и в одноимённый исторический элемент ленты: карточка оборванного вопроса после перезагрузки рисует те же кнопки. Клиент рисует известные ему решения, неизвестные молча пропускает — новое решение вводится правкой ядра, без выпуска клиентов. Выбранное решение операции клиент возвращает полемdecisionвops/result;denyбезerrorядро превращает в канонический отказ для модели. - Инварианты диалога одобрения, обязательные для всех клиентов: выбор
всегда порождает явное решение — молчаливое закрытие диалога
запрещено (закрыли крестиком — это
deny, и он уезжает ядру);Escв диалоге всегда значит «отказ», даже при перенастроенных клавишах — пользовательская настройка не должна превращать безопасное умолчание в разрешающее. - Зарезервировано контрактом:
kind: "permission"— запрос профиля разрешений (например{fileSystem: {write: [...]}}), на который клиент отвечает выданным подмножеством; ядро таких запросов пока не задаёт. /v1/youtube/*— библиотека Господина: подписки на каналы и история просмотра. Ядро тут общая полка клиентов (телефон, браузер, десктоп видят один список), в сам YouTube оно не ходит. Правила, которые клиентам не нужно повторять каждому у себя:PUT /v1/youtube/history/{video_id}— идемпотентная отметка просмотра: видео поднимается наверх истории со свежими метаданными. Место остановки хранится только между 2% и 97% длины ролика — ниже это случайный клик, выше досмотрено, а при неизвестной длине (duration_s≤ 0) мерить не от чего; во всех этих случаяхposition_sстановитсяnull, но запись остаётся: это история просмотров, а не список недосмотренного. Ответ отдаёт строку такой, какой она легла, — по нему клиент видит, пережило ли место остановки.- История помнит 200 последних видео, хвост уходит на каждой отметке.
beforeвGET /v1/youtube/history— курсор поvideo_idпоследней показанной строки, а не время: пачка, приехавшая одной секундой (импорт истории с телефона), при сравнении по времени теряла бы строки на границе страницы. Неизвестный id — 400, а не пустая страница.
- Кривое тело запроса (не-JSON, тело не сходится со схемой) axum отвергает сам — статусом 422 Unprocessable Entity, не 400; свои 400 ядро отдаёт уже поверх разобранного тела (кривой курсор, невалидные значения полей). Клиентской ветке «я прислал не то» стоит ждать обоих статусов.
- Аутентификации у ядра нет — её вешает reverse-proxy перед ним (у нас
Caddy требует
X-Eva-Token);/apidocsи/v1/openapi.jsonоткрыты.