Переведено с китайского оригинала. При расхождении приоритет у оригинала.
Confer — Спецификация API
Определяет все API между клиентом и сервером, а также между сервером и пирами A2A.
Общие соглашения
- Base URL:
https://{instance}/api - Кодировка: JSON, UTF-8
- Формат времени: ISO 8601, UTC (
2024-11-15T14:30:00Z) - Идентификаторы: ULID (
01HXKQ7Z2N3M4P5R6T7Y8Z9A0B) - Формат ошибки:
{
"error": {
"code": "invalid_request",
"message": "Human-readable message",
"details": { /* optional */ }
}
}
Аутентификация
- Клиент пользователя:
Authorization: Bearer <jwt_access_token> - TTL access-токена — 15 минут, TTL refresh-токена — 90 дней
- Два токена различаются утверждением
typ(access/refresh) и не взаимозаменяемы: заголовокAuthorizationпринимает толькоaccess, аPOST /auth/refresh— толькоrefresh. Раньше они отличались лишь полемexp, из-за чего refresh-токен работал как 90-дневный пропуск на любом защищённом эндпоинте, а 15 минут access-токена не значили ничего - Refresh обновляется при каждом использовании и сверяется с
sessions.refresh_token_hash; несовпадение считается повторным использованием, и вся сессия аннулируется.sessions.expires_at— абсолютный предел сессии: ротация его не продлевает - Токены хранятся в локальном хранилище клиента, а не в HTTP-only cookie (клиент — настольное приложение на Tauri, где у cookie того же origin просто нет аналога)
Клиентское 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):
- Все запросы ограничены
user.sub(изоляция между пользователями). - Перед PUT проверяется, что пир является контактом этого пользователя (
peer_contacts); если нет — возвращается403 not_a_contact. - PUT выполняет upsert: первая запись ставит
version=1, каждая следующая увеличиваетversionи обновляетupdated_at.factsиdecisionsнезависимы — запись одного раздела не стирает другой. GET facts/decisionsвозвращает200, пустую строку иversion:0, когда у пары (проект, пир) памяти ещё нет (а не 404: «этот пир пока ничего не оставил» — нормальное состояние при чтении).project_idпроверяется выражением^[a-zA-Z0-9._\-/]+$(от 1 до 255 символов); при несоответствии возвращается400 invalid_project_id.GET peersна пустом проекте возвращает пустой массив. Связь (проект, пир) возникает неявно при PUT facts/decisions (явной регистрации черезPOST peersна этом этапе нет).
База знаний (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. Возвращаются только данные вызывающего.
- Итоги по токенам не включают ходы
unreported; если ни один ход группы не отчитан, поля равныnull— «не сообщено» не значит «ноль». cache_*— частиinput_tokens, а не добавка к ним; ониnull, если провайдер ни разу не упоминал кэш.- Строки с
audience: peer— ответы подключённому контакту, их тоже оплачивает владелец. - Записываются только ходы агента (веб-чат и входящий A2A), по строке на ход, включая неудачные. Извлечение памяти и вызовы embedding не учитываются.
- Сумм в деньгах нет: цены моделей локально не хранятся, стоимость — по счёту провайдера.
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.
Несколько намеренных решений:
- По одной карточке на Агента, где
supportedInterfaces[].tenant— имя пользователя. Well-known в спецификации исходит из «один домен — один Агент», а этот экземпляр многоарендный;tenantи есть тот селектор маршрутизации, который спецификация вводит для случая «несколько Агентов за одним эндпоинтом A2A»./.well-known/agent-card.jsonотвечает, только когда публичный Агент ровно один (случай одиночного самостоятельного хостинга), иначе возвращает 404 и в тексте ошибки указывает наagents.json— назвать произвольно выбранную учётную запись «Агентом этого домена» было бы неправдой. streaming: true,pushNotifications: true.message:stream,:subscribeи маршруты настроек push отвечают в формах спецификации; потоковая передача — на уровне событий задачи (Task, затем одно итоговое состояние).securitySchemesне объявляются. Спецификация предлагает там ключ API, HTTP-аутентификацию, OAuth2, OIDC или mTLS, а этот эндпоинт не принимает ни одного из них: ему нужен подписанный запрос. Вписать что-нибудь наугад — значит сказать клиенту, что он может аутентифицироваться способом, который наверняка отвергнут. Настоящее требование объявлено как обязательное расширение (capabilities.extensions, гдеuri— адрес RFC 9421, аrequired: true), и это ровно тот механизм, который спецификация для этого и предусмотрела.-
Карточка — это документ обнаружения, и её видимость в точности совпадает с видимостью
/.well-known/agents.json: непубличный или отключённый Агент всегда отдаёт 404, иначе этот маршрут превратился бы в способ перечислить учётные записи, которые хозяин публиковать не собирался. - Объявляется только одна привязка. Собственный диалект Confer живёт по тому же URL, но в карточку не попадает: §5.1 требует, чтобы все объявленные Агентом привязки были функционально равнозначны, а у диалекта нет жизненного цикла задач. Он обнаруживается через
/.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 с.
200 { status: "answered", message }— ответ получен200 { status: "pending" }— время вышло, ответа нет; можно опросить позже
GET /api/v1/consult/:conversationId
Возвращает всю историю этой ветки консультации (не более 200 сообщений).
Контракт: входящий A2A запускает автоматический ответ локального агента только для
message.type==='question';answerиnotificationлишь сохраняются и рассылаются, чтобы ответ на консультацию не превратился в бесконечный обмен.