ai-agents42.9 КБ

Как я строил agent-first REST API и agent-first блог

Учёт финансов IAAM, где вместо интерфейса агент, и движок этого блога для чужих агентов и моего. Какие принципы из этого выросли и почему инструкция для агента рано или поздно врёт.

проекты: IAAM, oh-my-portal

Последние три недели я строил две системы, у которых главный пользователь — не человек. Первая — IAAM1, мой учёт финансов и инвестиций. Интерфейса у него нет вообще: я разговариваю с агентом, а агент — с REST API. Вторая — oh-my-portal2, движок этого блога. Его должны удобно читать чужие агенты, а писать в него — мой.

Код в обоих проектах почти целиком написали агенты, на мне были архитектура, решения и бесконечное «а теперь дай это другому агенту». И самое ценное, что я из этого вынес, — не код, а десяток простых принципов. Почти каждый вырос из одной и той же ситуации: агент споткнулся там, где человек даже не заметил бы проблемы.

Что я называю agent-first? Формального определения я не нашёл, так что это моя рабочая рамка: агент — первый, а иногда и единственный клиент системы, и всё, что нужно ему для действия, он находит и проверяет без догадок. Ничего революционного тут нет, большая часть — старые добрые свойства хорошего API. Агенты просто очень быстро находят места, где мы привыкли рассчитывать на человеческую сообразительность.

Схема двух проектов. IAAM: владелец разговаривает с агентом, секреты хранит CLI, агент идёт от каталога API к OpenAPI и к очереди действий, а скилл не содержит маршрутов. Блог: из Markdown в git сборка делает HTML, .md, .json, /index.json и llms.txt для чужого агента, а мой агент приносит изменения через pull request, который мержит владелец

Часть первая. IAAM: API без интерфейса

Человек не отправляет запросы, а секреты не проходят через модель

В самом начале у меня мелькнула мысль: часть настройки пусть владелец сделает сам, отправит пару запросов руками. И тут же сам себя остановил: «Пользователь пойдёт дёргать API? Это неверный подход». Если единственная поверхность владельца — агент, то любой ответ API в духе «а это сделает человек» означает, что это не сделает никто.

Вторая граница — секреты. Токен владельца, ключи брокера, ключ шифрования не должны попадать в разговор: всё, что оказалось в контексте модели, можно оттуда выманить подброшенной инструкцией. Поэтому первоначальную настройку и ключи я отдал консольной утилите. Агент знает ровно один секрет — собственный токен доступа.

Что делать дальше, отвечает система, а не документ

Сначала рядом с API жил скилл — инструкция для агента: как устроена система, в каком порядке всё настраивать, что ответит какой маршрут. Выглядело практично: зачем интерфейс, если можно один раз хорошо всё описать?

Беда в том, что код меняется, а текст остаётся таким, каким его написали. Мой скилл около недели уверял агентов, что синхронизация с брокером и правила классификации ещё не реализованы. Самое обидное, что они уже работали: фраза попала в инструкцию буквально следующим коммитом после рабочего кода. Агент читал инструкцию, верил ей и не делал того, что система уже умела. И это не баг в коде, который поймают тесты, — это враньё в тексте, которое не ловит никто.

В какой-то момент я задал вопрос по-другому: «Может быть нужна отдельная ручка, откуда агент узнает флоу, текущее состояние, чтобы не сопровождать отдельным скиллом?» Так появилась очередь действий. Агент спрашивает у API не «что ты умеешь», а «что мне сделать сейчас». В ответ он получает список: что мешает цели, каким вызовом это закрыть, каких данных не хватает и у кого их взять — у владельца, из документа или сам агент может их вычислить.

Вот один пункт такой очереди. Система просит записать остаток на счёте в конце периода, чтобы сверить его с тем, что она насчитала сама:

{
  "id": "provide_control_assertion:3f6c2a9e-8b41-4d7a-9c15-2e8f0b7d4a61:2026-06-01:2026-08-31:closing:cash",
  "kind": "provide_control_assertion",
  "category": "required_for_goal",
  "goals": ["reconciliation"],
  "state": "needs_owner_input",
  "reason": "Account 3f6c2a9e-8b41-4d7a-9c15-2e8f0b7d4a61 (Savings) has business facts from 2026-06-01 through 2026-08-31; record its closing cash balance. An assertion is evidence to reconcile, not proof of a match; a discrepancy may remain.",
  "subject": {
    "type": "account",
    "id": "3f6c2a9e-8b41-4d7a-9c15-2e8f0b7d4a61",
    "title": "Savings",
    "institution": "Northline"
  },
  "target": {
    "type": "operation",
    "operationId": "record_owner_balance",
    "method": "POST",
    "path": "/v1/reconciliation/balance",
    "requiredScope": "agent",
    "request": {
      "preset": {
        "account": "3f6c2a9e-8b41-4d7a-9c15-2e8f0b7d4a61",
        "at": "closing",
        "from": "2026-06-01",
        "to": "2026-08-31"
      },
      "missing": [
        {
          "pointer": "/cash",
          "provided_by": "owner",
          "prompt": {
            "ask": "How much money did this account hold on that date?",
            "consequence": "The figure is compared with what this system worked out from everything it has recorded for the account. If the two agree, that stretch is confirmed and you stop being asked about it. If they disagree, the difference is kept and shown to you rather than smoothed away — your figure does not overwrite the worked-out one, and neither of the two is assumed to be the right one."
          }
        }
      ]
    }
  }
}

Агенту не нужно ничего собирать самому. goals говорит, какой цели мешает пункт, target — какой вызов его закроет, и всё, что система знает, уже лежит в preset. Не хватает одного числа, и provided_by: owner говорит, что его знает только владелец. Вопрос для него тоже готов: ask — что спросить, consequence — что изменит ответ. Тексты в API на английском, а переформулировать их для меня своими словами — работа агента.

Одна деталь оказалась важнее, чем кажется. Пункт, пропавший из очереди, — ещё не сделанная работа: он мог пропасть, потому что удалили данные или что-то сломалось. Поэтому выполнение подтверждается отдельно, положительным фактом: система проверяет, что цель действительно достигнута.

Скилл при этом остался, но похудел. Он объясняет, зачем нужна система и где проходят границы агента. А называть маршруты, методы и коды ответов ему теперь запрещает автоматическая проверка в сборке: строка вроде POST /v1/instruments отвечает 501 в скилле просто не пройдёт проверку.

Готового стандарта нет, но словарь есть

Прежде чем изобретать очередь, я попросил агента поискать: «А существует какой-то стандарт или драфт стандарта API для агентов?» Готового не нашлось, зато нашлось, у кого взять слова.

Больше всего пригодился Arazzo3 — спецификация от OpenAPI Initiative, которая описывает цепочки вызовов для достижения цели. Оттуда пришло operationId: каждый пункт очереди и каждая ошибка называют нужный вызов тем же именем, что и в OpenAPI. Сервер даже не стартует, если какое-то действие не удаётся найти по имени. Из RFC 94574 я взял pointer — указатель на конкретное место в запросе, из HAL-FORMS5prompt, человекочитаемую подсказку к полю. А искать API агент начинает со стандартного каталога по RFC 97276.

Сам файл сценария Arazzo я заводить не стал. Рукописный сценарий — такой же документ, как скилл, и разошёлся бы с кодом ровно так же. К тому же критерий успеха в Arazzo проверяет ответ только что сделанного вызова, а мне нужно было условие для работы, которую ещё только предстоит сделать. Так что это вдохновение, а не совместимость, и я честно так это и записал.

Ошибка говорит, что делать дальше

Для человека ошибка «неверный запрос» — повод открыть документацию. Агент документацию не откроет. Он начнёт перебирать варианты или пойдёт спрашивать человека о том, что мог бы исправить сам.

Поэтому ошибка в IAAM отвечает на три вопроса. Где проблема — указатель на поле в том самом запросе, который агент только что отправил. Что здесь допустимо — тот же список вариантов, что публикует очередь, а не своё отдельное описание. И какой вызов сделать сначала, если никакое значение в этом поле не поможет и агенту нужно сперва сходить в другое место. Такой следующий шаг есть не у каждой ошибки, и это честно: придуманный ради красоты шаг отправил бы агента туда, где ему не помогут.

Вот как это выглядит, когда агент пытается завершить импорт, а на один вопрос по выписке ещё нет ответа. Ответ сокращён:

{
  "field": "session",
  "pointer": "/session",
  "resolutions": [
    {
      "operationId": "answer_import_question",
      "method": "POST",
      "path": "/v1/import-sessions/{session}/questions/{question}/answer",
      "requiredScope": "agent",
      "request": {
        "preset": {
          "session": "4f1c2a9e-7b3d-4e8a-9c21-5d6e8f0a1b2c",
          "question": "b7e9d4c1-2a5f-4c3e-8d17-9e0f1a2b3c4d"
        },
        "missing": [
          {
            "pointer": "/answer",
            "provided_by": "owner",
            "alternatives": [
              { "value": "paid" },
              {
                "value": "received_from_own_account",
                "requires": [
                  {
                    "candidates": [
                      { "id": "3f6c2a9e-8b41-4d7a-9c15-2e8f0b7d4a61", "title": "Savings" }
                    ]
                  }
                ]
              }
            ]
          }
        ]
      }
    }
  ]
}

Агенту не нужно ничего искать в документации. pointer показывает, что мешает сама сессия. В resolutions лежит вызов, который это исправит, причём адрес уже заполнен: preset содержит нужные идентификаторы, и собирать путь из кусочков агенту не придётся. requiredScope говорит, что его токена для этого хватит. А missing объясняет, чего не хватает: ответа, который даёт владелец (provided_by: owner), и какие ответы вообще возможны.

Хорошо видно, зачем это нужно, на первой загрузке выписки в пустую систему. Агент получил одну и ту же ошибку на каждую строку — сотни раз, хотя за всеми ними стояло несколько незнакомых системе счетов. Разбираться, что это одна проблема, пришлось бы агенту. Теперь ответ сам перечисляет, каких счетов не хватает и сколько строк на каждый приходится.

Рядом с идентификатором всегда имя

Представьте обычный разговор. Агент спрашивает: «У вас тут была операция такого-то числа, что это?» А человек в ответ: «А это по какому банку вопрос?» Именно так и было: агент после импорта спросил, что осталось, и получил дюжину одинаковых пунктов «внесите остаток», которые отличались друг от друга только идентификатором. Какой из них про какой банк, агент сказать не мог, хотя название счёта в системе было — просто в другом ответе. Тогда же я записал: «llm должна как можно меньше оперировать всякими id, она с ними плохо работает».

Решение получилось несимметричным. В ответах рядом с идентификатором всегда стоит человеческое название — счёта, банка, брокера. В примере с ошибкой выше это видно: кандидат на ответ приходит не голым id, а вместе с title. А на вход API принимает только идентификатор, который агент скопировал из ответа. Если принимать название, опечатка или переименование молча запишут данные не туда, и запрос при этом ещё и пройдёт успешно.

Вопрос человеку — тоже интерфейс

Агент в такой системе не только выполняет, но и спрашивает. И задаёт ровно те вопросы, которые дал ему API. Однажды агент добросовестно зачитал мне технические поля вместе с их описаниями из схемы. Ответить на такое невозможно, и я сформулировал правило, которое потом стало спецификацией:

каждый вопрос пользователю должен задаваться так, чтобы он понял, без наших внутренних терминов с объяснением для чего это и как это решение повлияет

Теперь у каждого поля, которое заполняет человек, есть готовый вопрос, а у каждого варианта ответа — что он изменит. Технические значения, которые человеку знать незачем, система придумывает сама.

Было и хуже. Как-то пришло несколько вопросов с совершенно одинаковым текстом. Я сопоставлял их со строками выписки на глаз, ошибся и ответил не на те строки. Это хуже, чем не ответить: неверный ответ принимается, и никто потом не узнает, что он был неверным. Теперь вопрос называет свою строку так, как её узнаёт человек, — по дате и сумме.

А первый настоящий импорт превратился в допрос: вопросов было очень много, и большинство повторялись. Тогда появилось ещё одно правило:

Агент должен быть проактивным и стараться минимизировать количество вопросов… Можно было бы спросить, ага, это выписка из [такого-то банка], значит все счета из него? Пользователь подтверждает сразу все вопросы.

Так и сделали: система предлагает одно решение сразу для группы. В том прогоне два подтверждения заменили полтора десятка вопросов.

Что прочитал, то и отправил

Модель узнаёт, как писать в API, копируя то, что она из него прочитала. Если форма чтения и форма записи разные, модель гадает, и каждая догадка стоит отклонённого запроса. У меня правило классификации читалось объектом, а принималось строкой с JSON внутри, и внешнему агенту понадобилось две попытки, чтобы угадать. Теперь тип один и тот же в обе стороны. Вот правило в списке правил (фрагмент):

{
  "id": "b7e1d0c4-5a92-4f3e-8d61-0c9a2f4e7b18",
  "version": 1,
  "matcher": { "counterparty_account": "Savings", "kind": "INNER" },
  "outcome": { "kind": "internal_transfer", "to": "3f6c2a9e-8b41-4d7a-9c15-2e8f0b7d4a61" }
}

А вот тело запроса, которым такое правило создаётся. matcher и outcome агент просто копирует из того, что прочитал:

{
  "matcher": { "counterparty_account": "Savings", "kind": "INNER" },
  "outcome": { "kind": "internal_transfer", "to": "3f6c2a9e-8b41-4d7a-9c15-2e8f0b7d4a61" }
}

Запрещать стоит только необратимое

Сначала я делил операции по важности: всё, что касается моих решений, — только через консоль. Выглядело безопасно, пока внешний агент не принёс отчёт. Большинство пунктов в его очереди требовали меня, хотя за ними стояло всего два повторяющихся решения. Формально система agent-first, а по факту агент постоянно говорит: «а теперь сделайте это сами». При таком количестве я бы очень скоро начал подтверждать не читая.

«Мы перегибаем с безопасностью, — написал я тогда. — …Запреты должны быть обоснованы, скорее на деструктивные события и те, которые нельзя откатить». Тут очень помог журнал, в который можно только дописывать: исправление в нём — новый факт, а старый остаётся на месте. Значит, почти всё можно откатить, и «у агента тоже должна быть возможность откатить транзакцию». За мной остались только токены и ключи. А вместо запретов появилась атрибуция: у каждого решения записано, кто его принял, и все их можно просмотреть одним списком.

Из того же отчёта выросло ещё одно правило: права — свойство конкретного вызова, а не целого пункта. Была проблема с тремя способами решения, и один из них был доступен агенту. Но весь пункт был помечен «для владельца», агент отфильтровал его по своему токену и так и не сделал то, что ему было можно.

Лучший ревьюер — агент, который видит систему впервые

Тесты проверяют то, о чём я подумал. Всё остальное находили полевые отчёты: я давал системе агента без моего контекста, реальную задачу и просил записать, где было плохо. После каждой волны исправлений — «будем с агентом тестить» — и новый отчёт.

Один такой агент прочитал правило про импорт, написанное для мира, где мы с ним сидим за одной клавиатурой, сделал совершенно правильный вывод, что импортировать ничего нельзя, и остановился. Правило привело ровно к тому, от чего должно было защищать. Другой дошёл до ответа, ни разу не открыв контракт, и угадал в трёх местах. И это когда у системы уже были и каталог, и очередь.

Часть вторая. Блог, который читают агенты

В 2021 году, выбирая платформу для блога7, я пообещал себе: сначала возьму готовое, а если энтузиазм не кончится, напишу свой движок. Взял Ghost. Энтузиазм не кончился.

Причина переезда в этот раз была конкретная. В Ghost статьи живут в базе данных, в формате его редактора, а мне нужно было, чтобы в блог писал агент и чтобы чужие агенты могли его нормально читать. Поэтому новый движок вырос из одного решения: источник правды — Markdown в git. Правка агента — обычный коммит, ревью — обычный diff, откат — обычный git revert. А Markdown-версия статьи для агентов ничего не стоит, потому что исходник и так Markdown.

Agent-first — это три разные вещи

Когда я начал проектировать, в одно слово «agent-first» у меня склеились три разные идеи: чужой агент читает сайт, мой агент пишет на сайт и агент живёт на сайте вместо интерфейса. Просил я первое, а проектирование быстро уехало во второе и третье — там интереснее. Но к тому, насколько удобно машине читать страницы, это имеет очень косвенное отношение.

Пришлось разложить всё по очереди и ввести правило: каждый следующий слой должен выключаться, а построенное до него — оставаться полезным. Без агента-жителя остаётся блог, в который пишет мой агент. Без агента-писателя — сайт, удобный для чужих агентов. А без всего этого — просто нормальный сайт для людей.

Работает только то, что опирается на привычки агентов

Читателя-агента я представлял довольно пессимистично. Он приходит по одной ссылке, делает один запрос и уходит. Не бродит по сайту, не видит кнопок, не угадывает адреса. А между сайтом и умной моделью часто стоит маленькая модель-пересказчик, которая первым делом теряет версии, флаги и команды.

Отсюда главное правило: всё, что требует сначала узнать про ваш сайт, работать не будет. Поэтому здесь нет MCP — «mcp долго и нудно подключать, к тому же mcp тратит контекст, зачем его кто-то будет держать постоянно?» Зато у каждой статьи рядом лежит .md-версия по предсказуемому адресу, весь каталог отдаётся одним /index.json, а в корне есть llms.txt8 — небольшой файл-оглавление со ссылками на Markdown.

Так выглядит одна запись каталога. Одного запроса к /index.json агенту хватает, чтобы выбрать нужные статьи у себя в контексте и сразу пойти за Markdown, ничего не угадывая:

{
  "title": "Hermes Agent вместо Claude Code",
  "slug": "hermes-agent-vs-claude-code",
  "canonical": "https://shady2k.ru/posts/hermes-agent-vs-claude-code/",
  "markdown": "https://shady2k.ru/posts/hermes-agent-vs-claude-code.md",
  "json": "https://shady2k.ru/posts/hermes-agent-vs-claude-code.json",
  "date": "2026-06-21",
  "lang": "ru",
  "author": "human",
  "summary": "Персональный ассистент на Hermes Agent без привязки к вендору…",
  "tags": ["ai-agents"],
  "projects": ["https://shady2k.ru/projects/hermes-lifemodel/"],
  "has_recipe": false
}

Здесь есть всё, что нужно для решения без второго запроса: о чём статья, на каком языке, кто её написал и есть ли в ней структурированный рецепт с командами. Поле has_recipe нужно, чтобы агент, которому нужны инструкции, сразу пропускал эссе.

Лучше всего было бы отдавать Markdown прямо по адресу статьи, если агент попросил его заголовком Accept: text/markdown. Но сайт лежит в объектном хранилище за CDN, а оно на заголовки запроса не смотрит. Держать ради одной возможности свой сервер, который однажды упадёт в три часа ночи, я не захотел. Если будете делать такое у себя, не забудьте про Vary: Accept9, иначе кеш законно запомнит первый ответ и начнёт раздавать Markdown браузерам.

И не путайте это с SEO. Google прямо пишет, что для поиска никакие AI-файлы и Markdown не нужны10. llms.txt нужен не поисковику, а агенту, которому нужна предсказуемая точка входа.

Упаковка может отличаться, факты — нет

Человеку и агенту не обязательно отдавать одно и то же. В Markdown у меня лежит текст, в JSON — метаданные, а прозу в JSON класть нет смысла: экранирование делает её только дороже для чтения. Это нормально, это разная упаковка. А вот если в HTML написано одно, а в Markdown другое, — это уже клоакинг, и сайт публикует две версии реальности.

Самое забавное, что на этом я попался сам, и ровно так же, как в IAAM. llms.txt предлагал агентам запросить Markdown заголовком Accept — текст писался до того, как я от этого отказался. Решение приняли, а оглавление для агентов о нём так и не узнало. Все тесты зелёные, и ни один не проверял, правду ли этот текст говорит про настоящий сайт. Теперь текст исправлен, а тест не даст обещанию вернуться:

- Каждый адрес отвечает и markdown: запросите его с `Accept: text/markdown`,
- либо возьмите `.md` напрямую. `/index.json` отдаёт весь каталог одним запросом.
+ У каждой записи есть markdown-версия по своему адресу, `.md` — ссылки ниже.
+ `/index.json` отдаёт весь каталог одним запросом.

Кто написал текст, решает не модель

Второй слой — мой агент, который пишет в блог. Он живёт на Hermes11: читает все статьи из репозитория, проверяет по эмбеддингам, не писал ли я уже об этом, пишет план и текст, прогоняет сборку, отдельная модель проверяет факты, агент смотрит на скриншоты страницы и открывает pull request. Дальше решаю я: статья попадает на сайт, когда я её смержу.

Кто автор, агент не решает. Если тему попросил я, в статье стоит author: human, если агент придумал её сам по расписанию — author: being, и на странице это видно. Выдать текст агента за человеческий — самый быстрый способ потерять доверие к сайту.

И моё любимое правило: прогон может закончиться ничем. Агент, который выдаёт статью каждый раз, когда просыпается, за неделю напишет сотню, и я просто перестану их открывать. Прогон, в котором не нашлось ничего стоящего, считается успешным.

Что я забираю с собой

Если свести всё к списку, который можно приложить к своей системе:

  1. Человек не отправляет запросы за агента, а секреты не проходят через модель.
  2. Что делать дальше, вычисляет система, а не пересказывает документ.
  3. Названия берутся из стандартов, но рукописных сценариев не заводится.
  4. Ошибка говорит, где проблема, что допустимо и какой шаг сделать сначала.
  5. Рядом с идентификатором всегда имя, на вход — только идентификатор.
  6. Вопрос человеку понятен ему самому и объясняет, что изменит ответ.
  7. Что прочитал, то и отправил.
  8. Запрещено только необратимое, права указаны у конкретного вызова.
  9. Свежий агент — лучший ревьюер.
  10. Каждый агентский слой выключается без ущерба остальным.
  11. Для чужих агентов — только привычное: одна ссылка, один запрос.
  12. Всё, что говорится агентам, генерируется и проверяется.
  13. Авторство определяет способ запуска, а публикует человек.

Начинал я с вопроса, какие особые интерфейсы нужны агентам. Ответ получился куда менее эффектным: им нужно то же, что и хорошему API всегда, только без скидки на человеческую сообразительность. Человек, наткнувшись на устаревший абзац, почешет затылок и что-нибудь придумает. Агент тоже придумает, только молча и с полной уверенностью.

Источники

  1. IAAM — страница проекта, исходники на GitHub

  2. oh-my-portal — страница проекта, исходники на GitHub

  3. The Arazzo Specification v1.1.0 — «The aim of the Arazzo Specification is to provide a mechanism that can define sequences of calls and their dependencies to be woven together and expressed in the context of delivering a particular outcome or set of outcomes when dealing with API descriptions (such as OpenAPI descriptions).»

  4. RFC 9457: Problem Details for HTTP APIs — «Each member is an object containing “detail” to describe the issue and “pointer” to locate the problem within the request’s content using a JSON Pointer.»

  5. The HAL-FORMS Media Type — «prompt: The human-readable prompt for the parameter.»

  6. RFC 9727: api-catalog — «It is intended to facilitate automated discovery and usage of published Application Programming Interfaces (APIs).»

  7. Муки выбора платформы для блога

  8. The /llms.txt file — «A proposal to standardise on using an /llms.txt file to provide information to help agents use a website.»

  9. RFC 9110: HTTP Semantics, Vary — «The “Vary” header field in a response describes what parts of a request message, aside from the method and target URI, might have influenced the origin server’s process for selecting the content of this response.»

  10. Google’s Guide to Optimizing for Generative AI Features on Google Search — «You don’t need to create new machine readable files, AI text files, markup, or Markdown to appear in Google Search (including its generative AI capabilities), as Google Search itself doesn’t use them.»

  11. Hermes Agent вместо Claude Code

похожие записи

// ближайшие соседи · cosine 0–1

  1. [0]0.864
    Архитектура ИИ-агента с желаниями или цифровой человек

    Проактивный агент lifemodel изнутри: сердцебиение раз в секунду, желания вместо опроса, слои без LLM, совет внутренних голосов с правом вето и честно о том, почему проект встал.

  2. [1]0.820
    Hermes Agent вместо Claude Code

    Персональный ассистент на Hermes Agent без привязки к вендору: память на Hindsight, самообучающиеся скиллы, cron и Telegram, плюс грабли Docker-бекенда, на которые я наступил.

  3. [2]0.819
    Два проекта за выходные: как ИИ-агенты изменили мою разработку

    Два пет-проекта за выходные без строчки кода руками, и что я узнал о Claude Code, Codex CLI, Roo Code и Gemini-CLI, их лимитах и реальной цене.

cosine embeddings · шкала 0–1 · вычислено при сборке

Набросок

Открыть оригинал