本ページは中国語の原文からの翻訳です。相違がある場合は原文が優先します。

Confer — API 仕様

クライアント ↔ サーバー、サーバー ↔ A2A peer のすべての API を定義する。

共通規約

{
  "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 */ }
}

2 段階認証(TOTP)。 有効にすると、login はパスワードが正しいうえで totp_code(認証アプリの 6 桁のコード)または recovery_code も求める。どちらもなければ 401 totp_required を返し、クライアントはこれを見て 2 段階目を表示する。コードが違えば 401 invalid_second_factor。パスワードが違う場合は常に先に invalid_credentials を返すので、2 段階認証の有無は漏れない。各コードは 1 回しか使えず(使用済みの時間ステップを記録)、リカバリーコードは使うと失効する。どちらも条件付き UPDATE 1 回で消費するため、同時のサインインで再利用はできない。setup が生成した秘密鍵は AES-256-GCM で暗号化して保存し、enable でアプリのコードを確認するまでは効力を持たない。enable はリカバリーコード 10 個をこの一度だけ返し、サーバーはハッシュだけを保持する。disable にはパスワードと第 2 要素が必要。password は現在のパスワードを求め、そのユーザーの他のセッションをすべて失効させる。これらはすべて監査ログに記録され、アカウントごとに 1 分 10 回までに制限される。 enable にも password が必要なので、盗まれたトークンだけで攻撃者の認証アプリを他人のアカウントに設定することはできない。2 段階認証がオンのとき、password によるパスワード変更にも第 2 要素が必要になる。同じアカウントで誤ったコードが 5 回続くと、そのアカウントのコード入力は 15 分間ロックされる(429 too_many_attempts)。これは IP ごとのログイン制限に上乗せされる。リカバリーコードはサーバー鍵による HMAC で保存する。認証アプリとリカバリーコードの両方を失ったユーザーには、管理者が POST /api/v1/admin/users/{id}/reset-2fa で 2 段階認証をオフにできる(監査ログに記録され、自分自身には使えない)。

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 を呼ぶスクリプトやツール向けで、セッションの access token と同じ Authorization: Bearer cfr_… ヘッダーを使う。トークンの平文は作成時に一度だけ返り、サーバーは SHA-256 と識別用のプレフィックスだけを保持する。有効期限は 1〜365 日で任意、1 ユーザーにつき最大 20 個で、所有者のアカウントが無効化されると直ちに使えなくなる。トークンはチャットでオーナーができることはできるが、次の操作はサインイン済みセッションのみを受け付け、トークンには 403 session_required を返す:ログアウト、パスワードと 2 段階認証、トークン自体の管理、権限リクエストと errand カードの承認、LLM キーの変更、すべての admin ルート。漏れたトークンはオーナーの用事はこなせても、オーナーに代わって決めることも、オーナーを締め出すこともできない。作成と失効は監査ログに記録される。

ユーザーと Agent の設定

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      # 各 provider が設定済みかどうか(真偽値のみを返し、鍵は返さない)
PUT    /api/v1/agents/me/llm-keys      # LLM の API キーを暗号化して保存
DELETE /api/v1/agents/me/llm-keys/{provider}
GET    /api/v1/agents/me/llm-keys/{provider}/models   # ベンダーに直接、利用できるモデルを問い合わせる

provider の値は @confer/shared の provider カタログ(packages/shared/src/llm/catalog.ts)から来る。加えてツールサービスの tavily。このカタログは gateway、agent-runtime、クライアントの三者が読む。base URL、モデル一覧のパス、既定モデルはその一箇所にしか書かれていないので、ベンダーの追加はカタログを直すだけで済む。

/models はベンダー自身のモデル一覧をそのまま転送し、ローカルで維持した一覧は決して返さない:

{ "models": [{ "id": "gpt-4o" }] }
// 空の一覧は必ず理由を伴う。4 つは互いに異なり、それぞれ取るべき手当ても違う
{ "models": [], "error": "no_key" }        // この provider にはまだ鍵が設定されていない
{ "models": [], "error": "unauthorized" }  // ベンダーがその鍵を拒否した(401/403)
{ "models": [], "error": "unreachable" }   // ベンダーに到達できない、または別のエラーが返った
{ "models": [], "error": "unsupported" }   // この provider はモデル一覧のエンドポイントを提供していない

連絡先 / Peer Agent

GET    /api/v1/contacts                     # 連絡先の一覧。ページング: ?limit=&offset=
POST   /api/v1/contacts                     # 連絡先を追加
GET    /api/v1/contacts/{contact_id}        # 連絡先 1 件の詳細(peer 付き)
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 はエラーにせず既定値を採る。

レスポンス: 見つかった候補 Agent の一覧。lookup は発見した peer を peer_agents に永続化し、各候補にローカルの id(peer_id)を付ける。POST /api/v1/contacts はまさにその id で連絡先を追加する。POST /contacts は冪等で、同じ peer を重ねて追加してもエラーではなく既存の連絡先(200)が返る。

連絡先の追加は、受け手が相手に「自分の Agent を消費してよい」と与える同意である。連絡先として追加された peer だけが、私の Agent に回答させ(私の LLM 予算を使わせ)られる。未接続の peer から届いた A2A メッセージは、承認待ちの接続リクエストとして保留される。03-protocol.md の「接続同意のゲート」を参照。

POST   /api/v1/contacts/{contact_id}/policies   # standing policy を設定(全体置換、PUT セマンティクス)

POST /contacts/{id}/policies の body は runtime 形の { default?: 'allow'|'ask_user'|'deny', rules?: [{ action, peer_did?, decision }] } で、まるごと peer_contacts.policy_overrides_json に書き込まれる。マージのセマンティクス: 受信 A2A の判定時、この contact 単位の上書きが agent レベルの policy に重なる。contact.default があれば agent の既定を置き換え、contact.rules は agent の rules の前に置かれるので先にマッチする(contact 単位の具体的な規則が agent の一般規則に優先する)。空の上書き {} は恒等で、上書きが無い場合の判定とバイト単位で一致する。

対話

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     # participant を追加
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、相手 agent の sender_name、content、content_json、citations_json が含まれる。返すのは事実だけで、Markdown は読み手の言語でクライアントが組み立てる(誰が「自分」で誰が「自分の Agent」か)。

POST /api/v1/conversations/{id}/messages リクエスト:

{
  "content_type": "text",
  "content": "X100 のレジスタ 0x40 にはどのファンクションコードを使いますか?",
  "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' のものは接続リクエストである(見知らぬ peer が初めて接触したときに A2A の受信経路が生成する)。承認(allow_*)するとその peer が peer_contacts に書き込まれて接続が成立し、拒否すれば成立しない。

action='ask' のものは接続済み peer からの保留中の質問で、その質問に対して Agent の policy が ask_user と判定したときに A2A の受信経路が生成する(03-protocol.md の「Pending inbox(不在時の代理応答)」を参照)。承認(allow_*)すると Agent が保留中の質問に答え、拒否すれば答えない。

allow_always / deny_always は継続的な決定である。保留中の質問に対しては、その連絡先の policy override の先頭に { action: 'ask', decision: 'allow' | 'deny' } ルールを書き込む(既存のものは置き換える)ため、この peer からの以後の質問は保留されずに応答または拒否される。オーナーは連絡先詳細の policy でこのルールを確認・取り消しできる。接続リクエストに対しては、deny_always によりその peer からの以後の接続リクエストは受信箱に届かず黙って破棄される。DID を指定して手動で追加することは引き続き可能である。

GET /pending は各リクエストに description を付ける(接続リクエストには発信者と最初のメッセージ、質問には発信者と質問本文)。持ち主が判断できるようにするためである。

プロジェクト記憶(Claude Code 連携まわり)

GET    /api/v1/projects/{project_id}/peers              # このプロジェクトで記憶を持つ peer(join で name/did も)  ✅ 実装済み
POST   /api/v1/projects/{project_id}/peers              # peer をプロジェクトに明示登録   🔜 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}                          # 名前・説明の変更、および外部 Agent への公開可否
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 upload、フィールド名は 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 の body は { name, description? }(name は 1–255 文字)で、201 と { knowledge_base } を返す。

PATCH /knowledge-bases/{kb_id} の body は { name?, description?, shared_with_peers? } で、{ knowledge_base } を返す。shared_with_peers はここでしか変更できず、作成時には受け付けない。ナレッジベースは必ず「自分だけ」で生まれ、外部への公開は second の、意図的な操作である。

shared_with_peers が決めるのは受信した A2A の質問がこのベースを検索できるかどうかで、既定は false。持ち主自身がウェブで対話するときには影響せず、常に全部を検索できる。この境界は必ず検索のスコープ側に置かねばならず、プロンプトに書いてはならない。相手の質問も持ち主の指示も、モデルにとっては同じ種類のテキストとして届くのだから、「言ってよいかは Agent が判断する」は境界を成さない。同じ理由で、受信した A2A の質問は長期記憶を一切リコールしない。長期記憶は持ち主自身の対話から蒸留されたものであり、このインスタンスの外へ出てよいと印の付いた項目は 1 件も無い。

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 で、1 ファイルの上限は 10 MB(超えると 400 bad_request)。Content-Type はフォームにあればそれを使い、無ければ拡張子から推測する。レスポンスは 201 と { document } で、その時点で status はすでに processing である。チャンク分割・ベクトル化・Qdrant への書き込みはレスポンスの後に非同期で走り、アップロードのエンドポイントはその完了を待たない。クライアントは status が変わるまでドキュメント一覧をポーリングする。

status の値:

値 意味
processing 取り込み済みで、分割・ベクトル化の最中。アップロード後と retry 後の初期状態
ready 検索できる。chunk_count はそのドキュメントの分割数
failed 取り込み失敗(解析、embedding キーの欠如、またはベクトルストアへの書き込み失敗)

POST /{doc_id}/retry はオブジェクトストレージから元ファイルを取り戻して再取り込みする。先にそのドキュメントの既存ベクトルを消すので、分割が重複することはない。元ファイルが失われている(storage_key が空)場合や、ドキュメントがまだ processing の場合は 400 を返す。レスポンスは { document } で、status は processing に戻り chunk_count はゼロになる。

ナレッジベースの削除は、その全ドキュメント行と Qdrant のベクトルにカスケードする。単一ドキュメントの削除では、ベクトルとオブジェクトストレージ上の元ファイルも併せて片付ける。ベクトルやオブジェクトの片付けに失敗しても DB の削除は止めない。削除済みデータを指す行を残すより、孤児オブジェクトを残すほうがましだからである。

すべてのエンドポイントは user.sub にスコープされる。他人の kb やドキュメントにアクセスすると 404 を返す(403 ではない。存在そのものを漏らさないため)。

リバースプロキシは 10 MB のリクエストボディを通す必要がある。infra/nginx.conf は /api/ に client_max_body_size 10m を設定している。nginx 既定の 1 MB のままだと、1–10 MB のファイルは gateway に届きすらせず、ブラウザは nginx 自身の 413 ページを受け取る。

ファイル添付

POST   /api/v1/attachments                       # multipart upload
GET    /api/v1/attachments/{id}/content          # ファイル本体:アップロードした本人、またはそのメッセージの会話の参加者に返す

アップロード直後の添付はどのメッセージにも紐づかず、POST /conversations/{id}/messages の attachment_ids(最大 5 個)で紐づける。紐づけられるのは送信者自身がアップロードした未送信のものだけ。本文がなく添付だけのメッセージも有効。画像はバイト列で判別し(PNG / JPEG / GIF / WebP、1 枚 5 MB まで)、画像ブロックとしてモデルに渡す。PDF / DOCX / XLSX / テキストはアップロード時に本文を抽出し(10 MB まで)、毎ターン <attachment> としてそのメッセージに添える。SVG はテキストとして扱い、画像としてインライン表示しない。content がインラインで返すのは判別済みの画像だけで、それ以外は application/octet-stream のダウンロードになる。メッセージ一覧の各メッセージには attachments が付く。会話を削除すると添付も削除される。

使用量

GET /api/v1/usage?month=YYYY-MM    # 1 か月分の Agent ターンのトークン使用量。省略時は今月

{ month, rows } を返します。各行は provider × model × audience の 1 グループで、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 であってはならない。3 つとも欠かせない。これらが無ければ、凍結されたアカウントもトークンが失効していない限り再接続してメッセージを受け取り続けられる一方、凍結そのもの(全セッションの削除)はこの経路では何も取り消せない。凍結は同時に、そのユーザーがすでに開いている socket も閉じる。nginx は /ws の proxy_read_timeout に 1 日を与えているので、次のハンドシェイクを止めても既に繋がっている接続は止まらない。

ハンドシェイクで確認したトークンが socket をいつまでも保証し続けるわけでもない。access token が失効するとサーバーは 4001 で接続を閉じ、クライアントはトークンを更新してから再接続する。ログアウトやリフレッシュトークンの再利用検知がセッションを削除したときも、そのセッションで開かれた socket を 1008 で閉じる。

メッセージ形式

WS のメッセージはすべて JSON で、type フィールドを持つ:

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

クライアント → サーバー

ping                          // ハートビート
subscribe.conversation        // 対話を購読(サーバー側で参加者かどうかを検証)
unsubscribe.conversation
typing.start                  // 購読済みの対話にのみ効く
typing.stop
read.ack                      // 既読確認

typing.* のブロードキャストは、その socket の購読集合に従う。購読にゲートがあって打鍵イベントに無い場合、対話 id を 1 つ知っているだけで、任意の対話に「誰それが入力中」を——しかも自分のユーザー名付きで——注入できてしまう。

サーバー → クライアント

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": "ja",
    "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 + peer の身元 + 保存済みの scope)だけを送り、承認時に読まれる文はクライアントが i18n で組み立てる(packages/client/src/lib/permission-text.ts)。この契約は @confer/shared の permissionRequestEventSchema が単独で所有する。gateway は送信前にこれで parse し、クライアントは受信後にこれで parse する。

GET /api/v1/permissions/pending の各行も同じ形(加えて decision フィールド)で、同じコンストラクタから生成される。したがってポーリングで得た行と socket が押し出した行はバイト単位で一致する。

SSE (LLM streaming)

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

イベント種別:

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

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}

A2A API(対外。他の Confer インスタンスが呼ぶ)

詳細は docs/03-protocol.md。ここではエンドポイントのみ列挙する。

2 つのバインディングが同じ prefix の下に共存し、同じゲート群(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

ストリーミングとプッシュ。 message:stream は message:send と同じ検証とゲートを通り、text/event-stream で応答する。イベントは 1 行の data: に 1 つの StreamResponse:まず {task}、タスクが終わる(または人の承認待ちの AUTH_REQUIRED になる)と {statusUpdate} を 1 つ送り、閉じる。このパスでは回答は 1 回の LLM 呼び出しで途中出力がないため、粒度はタスクイベント単位。:subscribe は GET と POST の両方を受け付け(proto は GET、spec の表は POST)、終了済みのタスクには UnsupportedOperation を返す。プッシュ:peer はタスクに webhook を登録でき(message:send の configuration.taskPushNotificationConfig でも可)、タスクの状態が変わるたびに {task} を 1 回 POST する。Authorization: {scheme} {credentials} を付け、token は X-A2A-Notification-Token で送る。webhook は https かつ公開アドレスに解決されなければならない(送信側 A2A と同じ SSRF 規則で、配信時にも再確認し、リダイレクトは追わない)。認証情報は封印して保存し、読み出し時は scheme だけを返す。タスクあたり最大 5 個で、登録した peer だけが参照・削除できる。

Confer 独自の方言(インスタンス間で使う。/.well-known/agents.json から発見される):

POST   /a2a/v1/messages                  # 外部 Agent のメッセージを受け取る
GET    /a2a/v1/stream/{message_id}       # 回答をストリームで取得(SSE)
GET    /a2a/v1/agent-facts/{agent_did}   # 公開 AgentFacts

すべての A2A エンドポイントで HTTP Message Signature の検証を要求する。例外は agent-facts だけで、/.well-known/agents.json と同じ公開の発見ドキュメントとして可視性もそろえている。非公開または停止中の Agent は一律 404。capabilities に載るのは、持ち主が保存した NANDA 形式({type, scope, languages})の能力だけである。

.well-known endpoints

GET    /.well-known/did.json                # 主 DID document
GET    /.well-known/agents.json             # このインスタンスの全公開 Agent 一覧
GET    /.well-known/agent-card.json         # A2A 標準 Agent Card(公開 Agent がちょうど 1 つのときのみ)
GET    /.well-known/openid-configuration    # 将来: OIDC 互換(v2)

A2A 標準 Agent Card(相互運用のための発見層)

GET    /agents/{username}/agent-card.json   # その Agent の A2A 標準 Card
GET    /.well-known/agent-card.json         # 同上。このインスタンスの公開 Agent がちょうど 1 つのときのみ

Linux Foundation の Agent2Agent v1.0 の AgentCard に従う(フィールドは a2aproject/A2A の specification/a2a.proto @ v1.0.1 から。proto3 の JSON マッピングなので camelCase)。目的は A2A エコシステムにこのインスタンスの Agent を発見させることにある。名前は重なっていたのに プロトコル が噛み合わなかった——相手側の発見ドキュメントは /.well-known/agent-card.json にあり、このインスタンスには /.well-known/agents.json しか無かったからだ。

いくつかの意図的な取捨:

メッセージ層(Task セマンティクス)

POST /a2a/v1/message:send は仕様の SendMessageRequest を受け取り、Task を返す。1 つの task は 1 回の受信質問そのものである。id はそのメッセージの id、contextId はそれを保管する対話であり、状態はその後に起きたことから導出される。同じ事実を影として持つ tasks テーブルを別に立てたりはしない。

Confer の非同期 + 同意ゲートのモデルは、仕様のステートマシンにちょうど乗る:

状況 状態
Agent が回答中 TASK_STATE_WORKING
回答完了 TASK_STATE_COMPLETED
このターンがそもそも動かない(モデル未設定、または provider がエラー) TASK_STATE_FAILED
ask_user の policy で保留、持ち主の承認待ち TASK_STATE_AUTH_REQUIRED(中断状態であって終端ではない)
持ち主が拒否した TASK_STATE_REJECTED

返すべき task が存在しないケースが 2 つある。行がそもそも作られないからだ——見知らぬ peer(承認待ちの接続リクエストとして保留される)と、policy による直接の拒否である。どちらも 403 PERMISSION_DENIED を返し、ErrorInfo.metadata.confer_status で区別する。次の呼び出しで 404 になる task id をでっち上げるほうが悪い。

残りの挙動も仕様と 1 つずつ揃えてある。エラーボディは google.rpc.Status の形で、ErrorInfo.reason を必ず伴う(複数の A2A エラーが同じ HTTP ステータスコードを共有しており、reason だけが唯一の識別手段だから)。必須拡張を宣言していないクライアントには §3.3.4 に従って ExtensionSupportRequiredError を返す。何も説明しない 401 ではない。historyLength=0 はフィールドごと省略することであって、空配列を送ることではない。nextPageToken は常に存在し、次ページが無いときは空文字列になる。

意図的な逸脱が 2 つあり、どちらもコードのコメントに書いてある。ブロッキングの message:send の待ちには上限がある(55 秒。その後は WORKING のままの task を返してクライアントにポーリングさせる)——§3.2.2 はタイムアウトの出口を用意しておらず、一方で LLM 呼び出しには上界が無い。そして messageId による冪等性(§3.3.1 の MAY)は実装していない。テナント安全な一意キーには owner のスコープが要るのに、最初のメッセージのワイヤ形式からはそれが取れないからである。

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/分 per IP
/api/v1/auth/register 3/時 per IP
/api/v1/conversations/*/messages POST 60/分 per user
/a2a/v1/* 60/分 per IP・パス、/a2a/v1 全体で 300/分 per IP
A2A の質問(両バインディング共通) peer DID ごと・オーナーごとに 20/分・300/日、同一ドメイン合計 60/分。超過 → 429 / RESOURCE_EXHAUSTED(Retry-After 付き)
/api/v1/consult/{peer} POST 30/分 per user
WSS 1 ユーザーあたり同時接続は最大 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 サーバー)が、すでに連絡先になっている peer agent に自分から質問し、非同期の返答を後から受け取れるようにする。署名と配送はすべて gateway 内で完結し、秘密鍵は gateway の外に出ない。

「対話 API」との違い: /api/v1/conversations + /api/v1/stream は自分のローカル LLM アシスタントとの会話であり、/api/v1/consult は A2A で他人の agent に送るものである。

POST /api/v1/consult/:peerId

type='consult' の対話を開始または継続し(peer ごとに同じ対話を再利用する)、message.type='question' に署名して配送する。

// リクエストボディ(consultRequestSchema)
{ "question": "鍵はどうやってローテーションしますか?", "code_context": "…任意のコード…", "language": "ja" }
レスポンス 意味
201 { conversation_id, message_id, status: "sent" } 署名して配送済み
502 { ..., status: "failed", error } 配送失敗(peer がオフライン / endpoint が無い / 署名の問題)
403 not_a_contact peer が現在のユーザーの連絡先ではない

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

peer の非同期返答をロングポーリングで待つ(返答は受信側の /a2a/v1/messages に thread_id を伴って届き、gateway がそれを元のスレッドに掛け戻す)。wait の上限は 55 秒。

GET /api/v1/consult/:conversationId

その相談スレッドの全メッセージ履歴を返す(最大 200 件)。

契約: 受信 A2A がローカル agent の自動返答を起動するのは message.type==='question' のときだけ。answer/notification は保存とブロードキャストのみで、相談の返答が無限の応酬を引き起こさないようにしてある。

← Confer に戻る A2A · DID:web · RFC 9421 · NANDA