Переведено с китайского оригинала. При расхождении приоритет у оригинала.

Confer — Спецификация API

Определяет все API между клиентом и сервером, а также между сервером и пирами A2A.

Общие соглашения

{
  "error": {
    "code": "invalid_request",
    "message": "Human-readable message",
    "details": { /* optional */ }
  }
}

Аутентификация

Клиентское API (его использует клиент пользователя)

Аутентификация

POST   /api/v1/auth/register
POST   /api/v1/auth/login
POST   /api/v1/auth/refresh
POST   /api/v1/auth/logout
POST   /api/v1/auth/password        # {current_password, new_password}
GET    /api/v1/auth/2fa             # {enabled, recovery_codes_left}
POST   /api/v1/auth/2fa/setup       # → {secret, otpauth_uri}
POST   /api/v1/auth/2fa/enable      # {code} → {recovery_codes}
POST   /api/v1/auth/2fa/disable     # {password, totp_code | recovery_code}
POST   /api/v1/auth/oauth/{provider}    # OAuth callback

POST /api/v1/auth/login запрос:

{
  "username": "laowang",
  "password": "...",
  "device_id": "ios-abc123",
  "device_info": { "platform": "ios", "model": "iPhone 15", "os": "17.1" }
}

Ответ:

{
  "access_token": "eyJ...",
  "refresh_token": "...",
  "expires_in": 900,
  "user": { /* User object */ }
}

Двухэтапный вход (TOTP). Когда он включён, login после верного пароля требует ещё totp_code (6 цифр из приложения-аутентификатора) или recovery_code; без них отвечает 401 totp_required — так клиент понимает, что нужно показать второй шаг, — а на неверный код отвечает 401 invalid_second_factor. На неверный пароль всегда сначала приходит invalid_credentials, поэтому наличие двухэтапного входа не раскрывается. Каждый код действует один раз (использованный временной шаг запоминается), а код восстановления вычёркивается после использования; оба расходуются одним условным UPDATE, так что одновременные входы не могут использовать один код дважды. Секрет из setup хранится зашифрованным AES-256-GCM и ни на что не влияет, пока enable не подтвердит код из приложения; enable один раз возвращает 10 кодов восстановления, а хранятся только их хеши. disable требует пароль и второй фактор. password требует текущий пароль и завершает все остальные сеансы. Всё это попадает в журнал аудита и ограничено 10 попытками в минуту на учётную запись. enable тоже требует password, поэтому одного украденного токена недостаточно, чтобы привязать аутентификатор злоумышленника к чужой учётной записи; при включённом двухэтапном входе password тоже требует второй фактор. Пять неверных кодов подряд блокируют ввод кодов для этой учётной записи на 15 минут (429 too_many_attempts) — сверх ограничения входа по IP. Коды восстановления хранятся как HMAC с ключом сервера. Пользователю, потерявшему и аутентификатор, и коды восстановления, администратор может отключить двухэтапный вход через POST /api/v1/admin/users/{id}/reset-2fa (с записью в журнал аудита; для собственной учётной записи администратора недоступно).

GET    /api/v1/tokens              # [{id, name, prefix, created_at, last_used_at, expires_at}]
POST   /api/v1/tokens              # {name, expires_in_days?} → {token: {..., token}}
DELETE /api/v1/tokens/{id}

Личные API-токены. Для скриптов и инструментов, вызывающих API от имени владельца: Authorization: Bearer cfr_… — тот же заголовок, что у access token сеанса. Токен в открытом виде возвращается только при создании; сервер хранит его SHA-256 и префикс для опознания. Необязательный срок действия от 1 до 365 дней, не больше 20 на пользователя; токен перестаёт работать, как только владелец отключён. Токен может делать то же, что владелец в чате, но следующие операции принимают только сеанс и отвечают токену 403 session_required: выход, пароль и двухэтапный вход, управление токенами, решения по запросам разрешений и карточкам errands, смена ключей LLM и все маршруты администратора. Утёкший токен может выполнять поручения владельца, но не решать за него и не запереть его снаружи. Создание и отзыв записываются в журнал аудита.

Настройки пользователя и Агента

GET    /api/v1/users/me
PATCH  /api/v1/users/me
GET    /api/v1/users/me/keys          # signing keys: current / retiring / revoked
POST   /api/v1/users/me/keys/rotate   # session only
POST   /api/v1/users/me/keys/revoke   # {key_id}, session only
GET    /api/v1/agents/me
PATCH  /api/v1/agents/me
PUT    /api/v1/agents/me/policies
GET    /api/v1/agents/me/llm-keys      # настроен ли каждый поставщик (возвращает только булевы значения, никогда сам ключ)
PUT    /api/v1/agents/me/llm-keys      # сохраняет ключи API для LLM в зашифрованном виде
DELETE /api/v1/agents/me/llm-keys/{provider}
GET    /api/v1/agents/me/llm-keys/{provider}/models   # запрашивает у поставщика в реальном времени список его моделей

Значения provider берутся из каталога поставщиков в @confer/shared (packages/shared/src/llm/catalog.ts) плюс инструментальный сервис tavily. Каталог читают сразу шлюз, agent-runtime и клиент: базовый URL, путь к списку моделей и модель по умолчанию записаны ровно в одном месте, поэтому добавление поставщика затрагивает только каталог.

/models пересылает список моделей самого поставщика и никогда не возвращает список, который ведётся у нас:

{ "models": [{ "id": "gpt-4o" }] }
// пустой список всегда несёт причину; все четыре различны и требуют разных действий
{ "models": [], "error": "no_key" }        // для этого поставщика ещё не настроен ключ
{ "models": [], "error": "unauthorized" }  // поставщик отклонил ключ (401/403)
{ "models": [], "error": "unreachable" }   // до поставщика не достучаться или он вернул иную ошибку
{ "models": [], "error": "unsupported" }   // этот поставщик не даёт эндпоинта со списком моделей

Контакты / чужие Агенты

GET    /api/v1/contacts                     # перечисляет контакты. Постраничность: ?limit=&offset=
POST   /api/v1/contacts                     # добавляет контакт
GET    /api/v1/contacts/{contact_id}        # подробности контакта (вместе с пиром)
DELETE /api/v1/contacts/{contact_id}
PATCH  /api/v1/contacts/{contact_id}        # частично меняет alias / tags / pinned / muted (непереданные поля не очищаются)

POST   /api/v1/contacts/lookup              # поиск по DID / домену / имени пользователя

POST /api/v1/contacts/lookup запрос:

{
  "method": "domain",          // domain | did | username | qr_code | phone
  "value": "abc-industries.com"
}

GET /api/v1/contacts возвращает { contacts, total }. limit по умолчанию 50 и не больше 100, offset по умолчанию 0; сортировка идёт по id (ULID) по убыванию, то есть самое свежее первым — именно единственный и детерминированный порядок не даёт окну offset пропускать или повторять строки. total — это полный счёт, а не число записей на странице, и по нему клиент понимает, что дошёл до конца. Нечитаемые limit и offset берут значения по умолчанию, а не приводят к ошибке.

Ответ: список найденных Агентов-кандидатов. Поиск записывает найденных пиров в peer_agents и добавляет к каждому кандидату локальный id (peer_id) — именно этот id и использует POST /api/v1/contacts для добавления контакта. POST /contacts идемпотентен: повторное добавление того же пира возвращает уже существующий контакт (200), а не ошибку.

Добавление контакта — это согласие принимающей стороны на то, чтобы другой расходовал её Агента: заставить моего Агента отвечать (и тратить мой бюджет на LLM) может только пир, добавленный в контакты. Сообщения A2A от неподключённого пира откладываются как запрос на соединение, ожидающий одобрения; см. «Ворота согласия на соединение» в 03-protocol.md.

POST   /api/v1/contacts/{contact_id}/policies   # задаёт постоянные политики (полная замена, семантика PUT)

Тело POST /contacts/{id}/policies имеет исполняемую форму { default?: 'allow'|'ask_user'|'deny', rules?: [{ action, peer_did?, decision }] } и целиком записывается в peer_contacts.policy_overrides_json. Семантика слияния: при решении по входящему запросу A2A это переопределение для контакта накладывается поверх политики Агента — если contact.default присутствует, он заменяет умолчание Агента, а contact.rules идут перед правилами Агента и потому срабатывают первыми (точное правило для контакта важнее общего правила Агента). Пустое переопределение {} — тождество: решение побайтово совпадает с тем, что было бы без него.

Разговоры

GET    /api/v1/conversations                       # перечисляет мои разговоры (для главного экрана)
POST   /api/v1/conversations                       # создаёт разговор
GET    /api/v1/conversations/{id}
PATCH  /api/v1/conversations/{id}
DELETE /api/v1/conversations/{id}

GET    /api/v1/conversations/{id}/messages         # постраничность: ?before=&limit=
POST   /api/v1/conversations/{id}/messages         # отправляет сообщение
GET    /api/v1/conversations/{id}/messages/{msg_id}/stream    # принимает ответ LLM потоком через SSE

POST   /api/v1/conversations/{id}/participants     # добавляет участника
DELETE /api/v1/conversations/{id}/participants/{p_id}

POST   /api/v1/conversations/{id}/read             # отмечает как прочитанное

GET по умолчанию возвращает неархивные беседы, а с ?archived=1 — архивные; у каждой есть unread_count — сколько сообщений написали другие с момента, когда пользователь последний раз отметил её прочитанной (или с момента вступления, если ни разу не отмечал); свои сообщения не считаются. PATCH принимает { name?, archived? }, и пользоваться им может только создатель беседы. POST .../read выставляет last_read_at вызывающего на текущее время. Если контакт пишет в архивную беседу, она выходит из архива и поднимается наверх.

GET /api/v1/search/messages?q=&limit= ищет подстроку в тексте сообщений во всех беседах, где участвует пользователь, включая архивные, от новых к старым; q — от 1 до 200 символов, limit по умолчанию 20, не больше 50. Ответ: { results: [{ message_id, conversation_id, conversation_name, sender_type, content, created_at }] }. Используется поиск по подстроке, а не полнотекстовый поиск Postgres, потому что встроенные конфигурации не разбивают китайский текст на слова.

GET /api/v1/conversations/{id}/export возвращает всю беседу, от старых сообщений к новым и не более 5000, только участникам: { conversation, messages, truncated }; у каждого сообщения есть sender_type, sender_did, sender_name агента собеседника, content, content_json и citations_json. Возвращаются только факты; Markdown клиент составляет на языке читателя (кто «вы», а кто «ваш агент»).

POST /api/v1/conversations/{id}/messages запрос:

{
  "content_type": "text",
  "content": "Какой код функции использует регистр 0x40 у X100?",
  "in_reply_to": null,
  "via": "web"
}

Ответ:

{
  "id": "01HXKQ...",
  "delivery_status": "queued",
  "stream_url": "/api/v1/conversations/01HX.../messages/01HXK.../stream"
}

Управление разрешениями

GET    /api/v1/permissions/pending               # ожидающие запросы уровней L2/L3
POST   /api/v1/permissions/{id}/decide           # одобрить или отклонить
GET    /api/v1/permissions/history               # история

POST /api/v1/permissions/{id}/decide запрос:

{
  "decision": "allow_always",       // allow_once | allow_always | deny | deny_always
  "scope": "peer_action"            // область действия решения
}

Среди ожидающих запросов те, у которых action='connect', — это запросы на соединение (их создаёт входящий путь A2A при первом обращении незнакомого пира). Одобрение (allow_*) записывает такого пира в peer_contacts и устанавливает связь; отказ — нет.

Те, у которых action='ask', — это отложенные вопросы от уже подключённого пира: входящий путь A2A создаёт их, когда политика Агента решает по этому вопросу ask_user (см. «Очередь ожидания (ответ в отсутствие хозяина)» в 03-protocol.md). Одобрение (allow_*) заставляет Агента ответить на отложенный вопрос; отказ оставляет его без ответа.

allow_always / deny_always — это постоянные решения. Для отложенного вопроса они записывают (или заменяют) правило { action: 'ask', decision: 'allow' | 'deny' } в начало политики этого контакта, поэтому следующие вопросы этого peer получают ответ или отказ без откладывания; владелец может увидеть и отменить правило в панели политики контакта. Для запроса на подключение deny_always приводит к тому, что следующие запросы этого peer молча отбрасываются и не попадают во входящие; добавить peer вручную по DID по-прежнему можно.

GET /pending снабжает каждый запрос полем description (у запроса на соединение — кто его начал и первое сообщение; у вопроса — кто спрашивает и текст вопроса), чтобы хозяину было на что опереться.

Память проекта (для интеграции с Claude Code)

GET    /api/v1/projects/{project_id}/peers              # пиры, у которых в этом проекте есть память (name/did подтягиваются join'ом)  ✅ реализовано
POST   /api/v1/projects/{project_id}/peers              # явно регистрирует пира в проекте   🔜 backlog
GET    /api/v1/projects/{project_id}/peers/{peer_id}/facts        # ✅ реализовано
PUT    /api/v1/projects/{project_id}/peers/{peer_id}/facts        # ✅ реализовано
GET    /api/v1/projects/{project_id}/peers/{peer_id}/decisions    # ✅ реализовано
PUT    /api/v1/projects/{project_id}/peers/{peer_id}/decisions    # ✅ реализовано

Пояснения к семантике (v0.1):

База знаний (RAG)

GET    /api/v1/knowledge-bases                                  # перечисляет мои базы знаний
POST   /api/v1/knowledge-bases                                  # создаёт базу
PATCH  /api/v1/knowledge-bases/{kb_id}                          # меняет имя или описание, а также открыта ли база внешним Агентам
DELETE /api/v1/knowledge-bases/{kb_id}                          # удаляет её вместе со всеми документами и векторами

GET    /api/v1/knowledge-bases/{kb_id}/documents                # постраничность: ?limit=&offset=
POST   /api/v1/knowledge-bases/{kb_id}/documents                # multipart-загрузка, поле называется file
DELETE /api/v1/knowledge-bases/{kb_id}/documents/{doc_id}
POST   /api/v1/knowledge-bases/{kb_id}/documents/{doc_id}/retry # переиндексировать

Тело POST /knowledge-bases — это { name, description? } (name от 1 до 255 символов); ответ — 201 и { knowledge_base }.

Тело PATCH /knowledge-bases/{kb_id} — это { name?, description?, shared_with_peers? }; ответ — { knowledge_base }. shared_with_peers меняется только здесь и не принимается при создании: любая база рождается «только для себя», а открытие её наружу — второе, отдельное и намеренное действие.

shared_with_peers решает, может ли входящий вопрос по A2A искать в этой базе, и по умолчанию равен false. На самого владельца, когда он общается в браузере, это не влияет: он всегда ищет по всему. Эта граница обязана проходить по области поиска, а не по промпту: вопрос чужой стороны и указания владельца доходят до модели как один и тот же вид текста, так что «пусть Агент сам решит, о чём можно говорить» границей не является вовсе. По той же причине входящий вопрос по A2A не поднимает никакой долговременной памяти: она выделена из разговоров самого владельца, и ни одна её запись не помечена как пригодная покинуть этот экземпляр.

GET /knowledge-bases возвращает { knowledge_bases } и не разбит на страницы: базы знаний пользователь заводит руками, и их число ограничено.

GET /{kb_id}/documents возвращает { documents, total }. limit по умолчанию 50 и не больше 100, offset по умолчанию 0; сортировка по id (ULID) по убыванию, то есть самое свежее первым — единственный и детерминированный порядок не даёт окну offset пропускать или повторять строки. total — полный счёт, а не число записей на странице. Нечитаемые limit и offset берут значения по умолчанию, а не приводят к ошибке. Это единственный список в этом разделе, растущий без границ, потому что база знаний — как раз то, куда загружают.

Загрузка идёт через multipart/form-data, поле файла всегда называется file, предел на файл — 10 МБ (сверх того 400 bad_request). Content-Type берётся из формы, если он там есть, иначе выводится из расширения. Ответ — 201 и { document }, причём status уже равен processing: нарезка, векторизация и запись в Qdrant выполняются асинхронно после ответа, эндпоинт загрузки их не ждёт. Клиент опрашивает список документов, пока status не изменится.

Значения status:

Значение Смысл
processing Сохранён, идёт нарезка или векторизация. Начальное состояние после загрузки и после retry
ready Доступен для поиска. chunk_count — число фрагментов документа
failed Индексация не удалась (разбор, отсутствие ключа для embedding или сбой записи в векторную базу)

POST /{doc_id}/retry забирает исходный файл из объектного хранилища и индексирует заново; сначала он стирает уже имеющиеся векторы этого документа, поэтому дублирующихся фрагментов не возникает. Возвращает 400, если исходного файла больше нет (storage_key пуст) или документ всё ещё в processing. Ответ — { document }, status сброшен в processing, chunk_count обнулён.

Удаление базы знаний каскадно удаляет все её строки документов и векторы в Qdrant; удаление одного документа вдобавок вычищает его векторы и исходный файл в объектном хранилище. Сбой очистки векторов или объектов не отменяет удаление в базе данных: осиротевший объект лучше строки, указывающей на уже удалённые данные.

Все эндпоинты ограничены user.sub: обращение к чужой базе или документу возвращает 404 (а не 403, чтобы не выдать сам факт существования).

Обратный прокси должен пропускать тела в 10 МБ. infra/nginx.conf задаёт client_max_body_size 10m на /api/; при стандартном для nginx значении в 1 МБ файлы от 1 до 10 МБ до шлюза попросту не доходят, а браузер получает собственную страницу 413 от nginx.

Вложения

POST   /api/v1/attachments                       # multipart upload
GET    /api/v1/attachments/{id}/content          # сам файл — загрузившему его или участникам беседы, где находится сообщение

Загруженный файл сначала ни к чему не привязан; POST /conversations/{id}/messages привязывает его через attachment_ids (не больше 5), причём привязать можно только собственные ещё не отправленные загрузки. Сообщение с вложениями и без текста допустимо. Изображения распознаются по байтам (PNG / JPEG / GIF / WebP, до 5 МБ каждое) и передаются модели как блоки изображений; PDF / DOCX / XLSX / текст читаются при загрузке (до 10 МБ), и их текст идёт вместе с сообщением как <attachment> в каждом ходе. SVG обрабатывается как текст и никогда не показывается как встроенное изображение. content отдаёт встроенно только распознанное изображение, всё остальное — как загрузку application/octet-stream. Каждое сообщение в списке содержит attachments. Удаление беседы удаляет и её вложения.

Использование

GET /api/v1/usage?month=YYYY-MM    # расход токенов на ходы агента за месяц; по умолчанию текущий месяц

Возвращает { month, rows }. Каждая строка — одна группа «провайдер × модель × аудитория»: turns, unreported (ходы, по которым провайдер не сообщил расход), failed, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens. Месяцы считаются по UTC; month не в формате YYYY-MM даёт 400. Возвращаются только данные вызывающего.

WebSocket

Эндпоинт

WSS  /ws?token=<access_token>&device_id=<device_id>

Аутентификация при рукопожатии полностью такая же, как в REST, а не «подпись сошлась — проходи»: typ обязан быть access, sid обязан указывать на всё ещё существующую сессию, а учётная запись не должна быть disabled. Все три условия необходимы: без них заблокированной учётной записи достаточно непросроченного токена, чтобы переподключаться и продолжать получать сообщения, тогда как сама блокировка (удаление всех сессий) на этом пути ничего не отзывает. Блокировка ещё и закрывает уже открытые сокеты этого пользователя: nginx даёт /ws proxy_read_timeout длиной в сутки, и остановка следующего рукопожатия не останавливает уже установленное соединение.

И токен, проверенный при рукопожатии, не ручается за сокет вечно: когда токен доступа истекает, сервер закрывает соединение с кодом 4001, и клиент обновляет токен, прежде чем переподключиться. Выход из системы или обнаружение повторного использования refresh-токена закрывает с кодом 1008 сокеты, открытые под удаляемой сессией.

Формат сообщений

Все сообщения WS — это JSON с полем type:

{ "type": "message.new", "data": { /* ... */ } }

Клиент → сервер

ping                          // сердцебиение
subscribe.conversation        // подписаться на разговор (сервер проверяет, что вы его участник)
unsubscribe.conversation
typing.start                  // действует только для разговоров, на которые есть подписка
typing.stop
read.ack                      // подтверждение прочтения

Рассылка typing.* опирается на набор подписок этого сокета. Когда у подписки ворота есть, а у событий набора текста нет, достаточно знать идентификатор разговора, чтобы вбросить в него «такой-то печатает» — да ещё и под собственным именем пользователя.

Сервер → клиент

pong
message.new                   // новое сообщение
message.updated               // зарезервировано, пока не отправляется
message.deleted               // зарезервировано, пока не отправляется
typing.update                 // кто печатает
presence.update               // контакт вышел на связь или ушёл
permission.request            // запрос разрешения, по которому должен решить пользователь
agent.status                  // мой Agent отвечает контакту: {conversation_id, state: thinking|tool|idle, tool?}; текст формирует клиент
conversation.updated          // контакт написал в {conversation_id} (возможно, в новую беседу); клиент обновляет список

message.new пример:

{
  "type": "message.new",
  "data": {
    "id": "01HXKQ...",
    "conversation_id": "01HX...",
    "sender_type": "peer_agent",
    "sender_id": "01HY...",
    "sender_did": "did:web:acme.com:agents:support",
    "content_type": "text",
    "content": "Кодом 0x03, Read Holding Registers…",
    "citations": [
      {
        "source": "Руководство по связи X100 v3.2",
        "page": 87,
        "url": "https://acme.com/manuals/x100-v3.2.pdf#page=87",
        "trust_level": "authoritative"
      }
    ],
    "language": "ru",
    "created_at": "2024-11-15T14:30:00Z"
  }
}

permission.request пример:

{
  "type": "permission.request",
  "data": {
    "id": "01HXP...",
    "level": "L2",
    "action": "share_files",
    "scope": {
      "peer": "did:web:acme.com:agents:support",
      "paths": ["src/modbus/"],
      "exclude": [".env", "secrets/"]
    },
    "peer_name": "ABC Agent",
    "peer_did": "did:web:acme.com:agents:support",
    "requested_at": "2024-11-15T14:30:00Z"
  }
}

В полезной нагрузке нет description, и это сделано намеренно. Сервер не знает, на каком языке читает получатель, поэтому шлёт только структурированные факты (action + личность пира + сохранённый scope), а фразу, которую видят при одобрении, собирает клиент по своей i18n (packages/client/src/lib/permission-text.ts). Этот контракт принадлежит исключительно permissionRequestEventSchema из @confer/shared: шлюз разбирает им данные перед отправкой, клиент — после получения.

Каждая строка GET /api/v1/permissions/pending имеет ту же форму (плюс поле decision) и создаётся тем же конструктором, поэтому строка, полученная опросом, и строка, пришедшая через сокет, совпадают побайтово.

SSE (LLM streaming)

GET  /api/v1/conversations/{id}/messages/{msg_id}/stream
Accept: text/event-stream

Типы событий:

event: token
data: {"text":"Кодом "}

event: token
data: {"text":"0x03 "}

event: tool_call
data: {"tool":"agent_network.ask_peer","args":{...}}

event: tool_result
data: {"result":"..."}

event: citation
data: {"source":"Руководство по связи X100 v3.2","page":87}

event: done
data: {"finish_reason":"stop","tokens_used":523}

API A2A (наружу, для вызова другими экземплярами Confer)

Подробности — в docs/03-protocol.md. Здесь только перечень эндпоинтов.

Две привязки сосуществуют под одним префиксом и проходят одни и те же ворота (a2a/inbound.ts); различается лишь формат передачи.

Стандартная привязка A2A HTTP+JSON (пути дословно взяты из §11.3 спецификации; именно её объявляет Agent Card):

POST   /a2a/v1/message:send              # SendMessage → Task
GET    /a2a/v1/tasks/{id}                # GetTask
GET    /a2a/v1/tasks                     # ListTasks (курсорная постраничность)
POST   /a2a/v1/tasks/{id}:cancel         # CancelTask → TaskNotCancelable
POST   /a2a/v1/message:stream            # SendStreamingMessage → SSE: Task, then statusUpdate
GET|POST /a2a/v1/tasks/{id}:subscribe    # SubscribeToTask → SSE
POST   /a2a/v1/tasks/{id}/pushNotificationConfigs            # → TaskPushNotificationConfig
GET    /a2a/v1/tasks/{id}/pushNotificationConfigs            # → {configs, nextPageToken}
GET    /a2a/v1/tasks/{id}/pushNotificationConfigs/{configId}
DELETE /a2a/v1/tasks/{id}/pushNotificationConfigs/{configId} # idempotent
GET    /a2a/v1/extendedAgentCard         # не реализовано → UnsupportedOperation

Потоковая передача и push. message:stream проходит те же проверки и шлюзы, что и message:send, и отвечает text/event-stream, по одной строке data: на StreamResponse: сначала {task}, затем один {statusUpdate}, когда задача завершается (или переходит в AUTH_REQUIRED, ожидая человека), после чего поток закрывается. На этом пути ответ — один ход LLM без промежуточного вывода, поэтому гранулярность — события задачи. :subscribe принимает GET и POST (в proto — GET, в таблицах спецификации — POST) и для завершённой задачи отвечает UnsupportedOperation. Push: peer регистрирует webhook на задаче (или передаёт configuration.taskPushNotificationConfig в message:send), и каждое достигнутое состояние отправляется один раз POST-запросом как {task} с Authorization: {scheme} {credentials}, а token — в X-A2A-Notification-Token. Webhook должен быть https и разрешаться в публичный адрес (то же правило SSRF, что и для исходящего A2A, повторно проверяется при доставке, перенаправления не выполняются); учётные данные хранятся запечатанными, наружу возвращается только scheme. Не более 5 на задачу; видеть и удалять их может только зарегистрировавший peer.

Собственный диалект Confer (между экземплярами; обнаруживается через /.well-known/agents.json):

POST   /a2a/v1/messages                  # принимает сообщения внешних Агентов
GET    /a2a/v1/stream/{message_id}       # забирает ответ потоком (SSE)
GET    /a2a/v1/agent-facts/{agent_did}   # публичные AgentFacts

Все эндпоинты A2A требуют проверки HTTP-подписи сообщения, кроме agent-facts: как и /.well-known/agents.json, это публичный документ обнаружения с той же видимостью, поэтому непубличный или отключённый Агент всегда отдаёт 404. В его capabilities попадает только то, что хозяин сохранил в форме NANDA ({type, scope, languages}).

.well-known endpoints

GET    /.well-known/did.json                # основной DID-документ
GET    /.well-known/agents.json             # список всех публичных Агентов этого экземпляра
GET    /.well-known/agent-card.json         # стандартная Agent Card A2A (только если у экземпляра ровно один публичный Агент)
GET    /.well-known/openid-configuration    # на будущее: совместимость с OIDC (v2)

Стандартная Agent Card A2A (слой совместимого обнаружения)

GET    /agents/{username}/agent-card.json   # стандартная A2A-карточка этого Агента
GET    /.well-known/agent-card.json         # то же самое, только если у экземпляра ровно один публичный Агент

Соответствует AgentCard из Agent2Agent v1.0 Linux Foundation (поля взяты из specification/a2a.proto репозитория a2aproject/A2A @ v1.0.1, в JSON-отображении proto3, отсюда camelCase). Цель — чтобы экосистема A2A находила Агентов этого экземпляра: имена совпадали, а протоколы не сходились, потому что документ обнаружения у той стороны лежит по /.well-known/agent-card.json, а у этого экземпляра был только /.well-known/agents.json.

Несколько намеренных решений:

Слой сообщений (семантика Task)

POST /a2a/v1/message:send принимает спецификационный SendMessageRequest и возвращает Task. Задача — это и есть один входящий вопрос: её id — идентификатор того сообщения, contextId — разговор, в котором оно хранится, а состояние выводится из происходящего дальше; отдельной таблицы tasks, дублирующей тот же факт, не заводится.

Асинхронная модель Confer с её воротами согласия ложится прямо на автомат состояний из спецификации:

Ситуация Состояние
Агент отвечает TASK_STATE_WORKING
Ответил TASK_STATE_COMPLETED
Этот ход вообще не может начаться (модель не настроена или поставщик вернул ошибку) TASK_STATE_FAILED
Отложено политикой ask_user, ждём хозяина TASK_STATE_AUTH_REQUIRED (состояние прерывания, не конечное)
Хозяин отказал TASK_STATE_REJECTED

В двух случаях возвращать нечего, потому что строка вовсе не создавалась: незнакомый пир (откладывается как запрос на соединение) и прямой отказ по политике. Оба отвечают 403 PERMISSION_DENIED и различаются через ErrorInfo.metadata.confer_status — выдумать идентификатор задачи, который на следующем вызове даст 404, было бы хуже.

Остальное поведение выверено по спецификации пункт за пунктом: тело ошибки имеет форму google.rpc.Status и всегда содержит ErrorInfo.reason (несколько ошибок A2A делят один HTTP-код, и reason — единственное поле, которое их различает); клиенту, не объявившему обязательное расширение, отвечают ExtensionSupportRequiredError по §3.3.4, а не ничего не объясняющим 401; historyLength=0 означает опустить поле целиком, а не прислать пустой массив; nextPageToken присутствует всегда и равен пустой строке, когда следующей страницы нет.

Два намеренных отступления, оба записаны в комментариях к коду: ожидание блокирующего message:send ограничено сверху (55 с, после чего возвращается задача всё ещё в состоянии WORKING, чтобы клиент опрашивал) — §3.2.2 не предусматривает выхода по времени, а у вызова LLM верхней границы нет; идемпотентность по messageId (это MAY из §3.3.1) не сделана, потому что безопасный между арендаторами уникальный ключ требует области владельца, а формат передачи первого сообщения её не несёт.

Webhooks (необязательно, v1.5+)

Позволяют внешним системам подписываться на события:

POST   /api/v1/webhooks
GET    /api/v1/webhooks
DELETE /api/v1/webhooks/{id}

Поддерживаемые события: message.new.peer, permission.granted, thread.archived.

Политика ограничения частоты

Маршрут Ограничение
/api/v1/auth/login 10 в минуту на IP
/api/v1/auth/register 3 в час на IP
/api/v1/conversations/*/messages POST 60 в минуту на пользователя
/a2a/v1/* 60 в минуту на IP и путь, 300 в минуту на IP по всему /a2a/v1
Вопросы A2A (обе привязки) На DID собеседника и владельца 20/мин и 300/сутки; на домен собеседника 60/мин. Сверх лимита → 429 / RESOURCE_EXHAUSTED с Retry-After
/api/v1/consult/{peer} POST 30/мин на пользователя
WSS не более 10 одновременных соединений на пользователя

Ответ при превышении:

HTTP/1.1 429 Too Many Requests
Retry-After: 30
Content-Type: application/json

{ "error": { "code": "rate_limited", "message": "Too many requests" } }

API консультаций (исходящий A2A по инициативе пользователя)

Позволяет пользователю (или действующему от его имени MCP-серверу) по собственной инициативе задать вопрос чужому Агенту, который уже является контактом, и затем забрать асинхронный ответ. Подпись и доставка целиком выполняются внутри шлюза; закрытый ключ его не покидает.

Отличие от «API разговоров»: /api/v1/conversations + /api/v1/stream — это беседа с собственным локальным LLM-помощником; /api/v1/consult — это то, что уходит по A2A к чужому Агенту.

POST /api/v1/consult/:peerId

Начинает или продолжает разговор с type='consult' (для каждого пира он один и тот же), подписывает и доставляет message.type='question'.

// тело запроса (consultRequestSchema)
{ "question": "Как ротировать ключи?", "code_context": "…необязательный код…", "language": "ru" }
Ответ Смысл
201 { conversation_id, message_id, status: "sent" } подписано и доставлено
502 { ..., status: "failed", error } доставка не удалась (пир не в сети, нет эндпоинта или проблема с подписью)
403 not_a_contact пир не является контактом текущего пользователя

GET /api/v1/consult/:conversationId/reply?after=:messageId&wait=:seconds

Длинным опросом ждёт асинхронный ответ пира (он приходит на входящий /a2a/v1/messages со своим thread_id, и шлюз подвешивает его обратно в нужную ветку). Предел wait — 55 с.

GET /api/v1/consult/:conversationId

Возвращает всю историю этой ветки консультации (не более 200 сообщений).

Контракт: входящий A2A запускает автоматический ответ локального агента только для message.type==='question'; answer и notification лишь сохраняются и рассылаются, чтобы ответ на консультацию не превратился в бесконечный обмен.

← Вернуться в Confer A2A · DID:web · RFC 9421 · NANDA