Translated from the Chinese original. Where the two disagree, the original is authoritative.

Confer — API specification

Defines every API between client and server, and between server and A2A peers.

General conventions

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

Authentication

Client API (used by the user client)

Authentication

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 request:

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

Response:

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

Two-step sign-in (TOTP). Once on, login also needs a totp_code (6 digits from an authenticator app) or a recovery_code after a correct password; with neither it answers 401 totp_required, which is how the client knows to show the second step, and a wrong one is 401 invalid_second_factor. A wrong password always answers invalid_credentials first, so it never reveals whether the account has two-step sign-in. Each code works once (the used time step is recorded) and each recovery code is struck off when used; both are spent by one conditional UPDATE, so concurrent sign-ins cannot reuse one. The secret from setup is stored under AES-256-GCM and does nothing until enable confirms a code from the app; enable returns the 10 recovery codes this once and only their hashes are kept. disable takes the password and a second factor. password needs the current password and signs out every other session. All of these are audited and limited to 10 a minute per account. enable also takes password, so a stolen token alone cannot put an attacker’s authenticator in front of someone’s account; with two-step sign-in on, password also takes a second factor. Five wrong codes in a row lock code attempts for that account for 15 minutes (429 too_many_attempts), on top of the per-IP login limit. Recovery codes are stored as an HMAC under the server key. For a user who has lost both their authenticator and their recovery codes, an admin can turn it off with POST /api/v1/admin/users/{id}/reset-2fa (audited, and never for the admin’s own account).

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}

Personal API tokens. For scripts and tools that call the API as the owner: Authorization: Bearer cfr_…, the same header a session’s access token uses. The token is returned in plain text only when it is created; the server keeps its SHA-256 and a prefix to recognise it by. An optional expiry of 1–365 days, at most 20 per user, and a token stops working the moment its owner is disabled. A token can do what the owner can do in chat, but these accept only a signed-in session and answer a token with 403 session_required: logout, password and two-step sign-in, managing tokens, deciding permission requests and errand cards, changing LLM keys, and every admin route. A leaked token can run the owner’s errands; it cannot decide for them or lock them out. Creating and revoking are audited.

User and Agent configuration

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      # whether each provider is configured (returns booleans only, never a key)
PUT    /api/v1/agents/me/llm-keys      # stores LLM API keys encrypted
DELETE /api/v1/agents/me/llm-keys/{provider}
GET    /api/v1/agents/me/llm-keys/{provider}/models   # asks the vendor live which models it offers

provider values come from the provider catalogue in @confer/shared (packages/shared/src/llm/catalog.ts), plus the tool service tavily. The gateway, agent-runtime and the client all read that catalogue: the base URL, the models-list path and the default model are written in that one place, so adding a vendor means touching the catalogue and nothing else.

/models forwards the vendor’s own model list and never returns a list maintained locally:

{ "models": [{ "id": "gpt-4o" }] }
// an empty list always carries its reason; the four are distinct and each calls for a different remedy
{ "models": [], "error": "no_key" }        // no key configured for this provider yet
{ "models": [], "error": "unauthorized" }  // the vendor rejected the key (401/403)
{ "models": [], "error": "unreachable" }   // the vendor could not be reached, or returned some other error
{ "models": [], "error": "unsupported" }   // this vendor offers no models-list endpoint

Contacts / peer Agents

GET    /api/v1/contacts                     # list contacts. Pagination: ?limit=&offset=
POST   /api/v1/contacts                     # add a contact
GET    /api/v1/contacts/{contact_id}        # one contact in detail (with its peer)
DELETE /api/v1/contacts/{contact_id}
PATCH  /api/v1/contacts/{contact_id}        # partially update alias / tags / pinned / muted (omitted fields are not cleared)

POST   /api/v1/contacts/lookup              # look up by DID / domain / username

POST /api/v1/contacts/lookup request:

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

GET /api/v1/contacts returns { contacts, total }. limit defaults to 50 and caps at 100, offset defaults to 0; rows are ordered by id (a ULID) descending, newest first — a unique, deterministic order is exactly what keeps the offset window from skipping or repeating rows. total is the full count, not the size of this page, and it is how the client knows it has reached the end. A limit or offset that cannot be parsed falls back to the default rather than erroring.

Response: the list of candidate Agents found. Lookup persists the peers it discovers into peer_agents and attaches each candidate’s local id (peer_id) — and that id is precisely what POST /api/v1/contacts uses to add the contact. POST /contacts is idempotent: adding the same peer twice returns the existing contact (200) rather than an error.

Adding a contact is the receiving side’s consent that the other party may consume its Agent: only a peer added as a contact can make my Agent answer (and spend my LLM budget). A2A messages from an unconnected peer are held as a connection request awaiting approval — see “The connection consent gate” in 03-protocol.md.

POST   /api/v1/contacts/{contact_id}/policies   # set standing policies (whole-object replacement, PUT semantics)

The body of POST /contacts/{id}/policies is the runtime shape { default?: 'allow'|'ask_user'|'deny', rules?: [{ action, peer_did?, decision }] } and is written wholesale into peer_contacts.policy_overrides_json. Merge semantics: when deciding an inbound A2A request, this per-contact override layers on top of the agent-level policy — a present contact.default replaces the agent default, and contact.rules are prepended to the agent’s rules so they match first (a precise per-contact rule beats a general agent rule). An empty override {} is the identity: the decision is byte-for-byte the one you would get with no override at all.

Conversations

GET    /api/v1/conversations                       # list my conversations (for the home screen)
POST   /api/v1/conversations                       # create a conversation
GET    /api/v1/conversations/{id}
PATCH  /api/v1/conversations/{id}
DELETE /api/v1/conversations/{id}

GET    /api/v1/conversations/{id}/messages         # pagination: ?before=&limit=
POST   /api/v1/conversations/{id}/messages         # send a message
GET    /api/v1/conversations/{id}/messages/{msg_id}/stream    # receive the LLM's reply as an SSE stream

POST   /api/v1/conversations/{id}/participants     # add a participant
DELETE /api/v1/conversations/{id}/participants/{p_id}

POST   /api/v1/conversations/{id}/read             # mark as read

GET lists unarchived conversations by default and archived ones with ?archived=1; each carries unread_count, the messages others wrote since the user last marked it read (or since they joined, if they never have) — their own do not count. PATCH takes { name?, archived? } and only the conversation’s creator may use it. POST .../read sets the caller’s last_read_at to now. A contact writing into an archived conversation unarchives it and moves it to the top.

GET /api/v1/search/messages?q=&limit= searches message text by substring across every conversation the caller takes part in, archived ones included, newest first; q is 1–200 characters and limit defaults to 20, at most 50. It returns { results: [{ message_id, conversation_id, conversation_name, sender_type, content, created_at }] }. Substring matching rather than Postgres full-text search, because the built-in configurations do not segment Chinese.

GET /api/v1/conversations/{id}/export returns the whole conversation, oldest first and at most 5000 messages, to participants only: { conversation, messages, truncated }, each message carrying sender_type, sender_did, the peer agent’s sender_name, content, content_json and citations_json. It returns facts only; the client writes the Markdown in the reader’s language (who “you” and “your agent” are).

POST /api/v1/conversations/{id}/messages request:

{
  "content_type": "text",
  "content": "Which function code does the X100's 0x40 register use?",
  "in_reply_to": null,
  "via": "web"
}

Response:

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

Permission management

GET    /api/v1/permissions/pending               # pending L2/L3 requests
POST   /api/v1/permissions/{id}/decide           # approve or deny
GET    /api/v1/permissions/history               # history

POST /api/v1/permissions/{id}/decide request:

{
  "decision": "allow_always",       // allow_once | allow_always | deny | deny_always
  "scope": "peer_action"            // scope of the decision
}

Among the pending requests, those with action='connect' are connection requests (generated by the inbound A2A path when an unknown peer makes first contact). Approving (allow_*) writes that peer into peer_contacts and establishes the connection; denying does not.

Those with action='ask' are pending questions from an already-connected peer — the inbound A2A path generates them when the Agent’s policy resolves to ask_user for that question (see “Pending inbox (answering while away)” in 03-protocol.md). Approving (allow_*) makes the Agent answer the suspended question; denying leaves it unanswered.

allow_always / deny_always are standing decisions. On a held question they write (or replace) a { action: 'ask', decision: 'allow' | 'deny' } rule at the front of that contact’s policy override, so this peer’s later questions are answered or refused without being held; the owner can see and revoke the rule in the contact’s policy panel. On a connection request, deny_always makes later connection requests from that peer drop silently instead of reaching the inbox; adding the peer by DID by hand still works.

GET /pending attaches a description to each request (a connection request carries who started it and their first message; a question carries who asked and the text) so the owner has something to judge by.

Project memory (part of the Claude Code integration)

GET    /api/v1/projects/{project_id}/peers              # peers with memory in this project (name/did joined in)  ✅ implemented
POST   /api/v1/projects/{project_id}/peers              # register a peer into the project explicitly   🔜 backlog
GET    /api/v1/projects/{project_id}/peers/{peer_id}/facts        # ✅ implemented
PUT    /api/v1/projects/{project_id}/peers/{peer_id}/facts        # ✅ implemented
GET    /api/v1/projects/{project_id}/peers/{peer_id}/decisions    # ✅ implemented
PUT    /api/v1/projects/{project_id}/peers/{peer_id}/decisions    # ✅ implemented

Semantics (v0.1):

Knowledge base (RAG)

GET    /api/v1/knowledge-bases                                  # list my knowledge bases
POST   /api/v1/knowledge-bases                                  # create one
PATCH  /api/v1/knowledge-bases/{kb_id}                          # rename/redescribe, and whether it is open to external Agents
DELETE /api/v1/knowledge-bases/{kb_id}                          # delete it along with every document and vector in it

GET    /api/v1/knowledge-bases/{kb_id}/documents                # pagination: ?limit=&offset=
POST   /api/v1/knowledge-bases/{kb_id}/documents                # multipart upload, field named file
DELETE /api/v1/knowledge-bases/{kb_id}/documents/{doc_id}
POST   /api/v1/knowledge-bases/{kb_id}/documents/{doc_id}/retry # re-index

The body of POST /knowledge-bases is { name, description? } (name 1–255 characters); the response is 201 with { knowledge_base }.

The body of PATCH /knowledge-bases/{kb_id} is { name?, description?, shared_with_peers? }; the response is { knowledge_base }. shared_with_peers can only be changed here and is not accepted at creation: every base is born “mine only”, and opening it outward is a second, deliberate act.

What shared_with_peers decides is whether an inbound A2A question can search this base, and it defaults to false. It does not affect the owner talking in the browser: they always search everything. This boundary has to fall on the scope of retrieval rather than in the prompt: the peer’s question and the owner’s instructions reach the model as the same kind of text, so “the Agent will judge what it should reveal” is no boundary at all. For the same reason an inbound A2A question recalls no long-term memory: long-term memory is distilled from the owner’s own conversations, and not one entry of it is marked as fit to leave this instance.

GET /knowledge-bases returns { knowledge_bases } and is not paginated: a user’s bases are created by hand and bounded in number.

GET /{kb_id}/documents returns { documents, total }. limit defaults to 50 and caps at 100, offset defaults to 0; ordered by id (a ULID) descending, newest first — a unique, deterministic order is what keeps the offset window from skipping or repeating rows. total is the full count, not the size of this page. An unparseable limit or offset falls back to the default rather than erroring. This is the only list in this section that grows without bound, because the knowledge base is exactly where uploads go.

Uploads go through multipart/form-data, the file field is always called file, and a single file is capped at 10 MB (beyond that, 400 bad_request). Content-Type is taken from the form when present and inferred from the extension when absent. The response is 201 with { document }, and status is already processing by then: chunking, embedding and writing to Qdrant all run asynchronously after the response, and the upload endpoint does not wait for them. The client therefore polls the document list until status changes.

status values:

Value Meaning
processing Stored; being chunked and embedded. The initial state after both an upload and a retry
ready Searchable. chunk_count is the number of chunks for that document
failed Indexing failed (parsing, a missing embedding key, or a failed write to the vector store)

POST /{doc_id}/retry fetches the original file back from object storage and re-indexes it, clearing that document’s existing vectors first so no duplicate chunks appear. It returns 400 if the original file is gone (storage_key empty) or the document is still processing. The response is { document }, with status reset to processing and chunk_count back to zero.

Deleting a knowledge base cascades to every document row and every vector in Qdrant; deleting a single document also cleans up its vectors and its original file in object storage. A failed vector or object cleanup does not block the database delete — an orphaned object is better than a row pointing at data that is already gone.

Every endpoint is scoped to user.sub: reaching for someone else’s base or document returns 404 (not 403, which would leak its existence).

The reverse proxy has to allow 10 MB bodies. infra/nginx.conf sets client_max_body_size 10m on /api/; at nginx’s own default of 1 MB, files between 1 and 10 MB never reach the gateway at all and the browser gets nginx’s own 413 page.

Attachments

POST   /api/v1/attachments                       # multipart upload
GET    /api/v1/attachments/{id}/content          # the file itself, to its uploader or a participant of its message's conversation

An upload starts unbound; POST /conversations/{id}/messages claims it through attachment_ids (at most 5), and only the sender’s own, not-yet-sent uploads can be claimed. A message with attachments and no text is valid. Images are recognised by their bytes (PNG / JPEG / GIF / WebP, at most 5 MB each) and reach the model as image blocks; PDF / DOCX / XLSX / text files are read at upload (at most 10 MB) and their text rides with the message as <attachment> on every turn. SVG is treated as text and never inlined as an image. content serves only a recognised image inline; everything else is an application/octet-stream download. Each message in the message list carries attachments. Deleting a conversation deletes its attachments.

Usage

GET /api/v1/usage?month=YYYY-MM    # token usage of agent turns for one month; defaults to the current month

Returns { month, rows }. Each row is one provider × model × audience group: turns, unreported (turns whose provider reported no usage), failed, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens. Months are cut in UTC; a month that is not YYYY-MM returns 400. Only the caller’s own data is returned.

WebSocket

Endpoint

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

Handshake authentication is identical to REST, not “the signature checks out, let it through”: typ must be access, sid must point at a session that still exists, and the account must not be disabled. All three are needed — without them a banned account only needs an unexpired token to keep reconnecting and receiving messages, while the ban itself (deleting every session) revokes nothing on this path. Banning also closes the sockets that user already has open: nginx gives /ws a proxy_read_timeout of a day, and stopping the next handshake does not stop an established connection.

Nor does the token checked at the handshake vouch for the socket indefinitely: when the access token expires the server closes the connection with 4001, and the client refreshes its token before reconnecting. Logging out, or refresh-token reuse detection, closes the sockets opened under the session it deletes with 1008.

Message format

Every WS message is JSON and carries a type field:

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

Client → server

ping                          // heartbeat
subscribe.conversation        // subscribe to a conversation (the server verifies participation)
unsubscribe.conversation
typing.start                  // only takes effect on conversations already subscribed to
typing.stop
read.ack                      // read receipt

typing.* is broadcast according to that socket’s subscription set. When subscription has a gate and typing events do not, knowing a single conversation id is enough to inject “so-and-so is typing” into it — under your own username, at that.

Server → client

pong
message.new                   // new message
message.updated               // reserved, not sent yet
message.deleted               // reserved, not sent yet
typing.update                 // who is typing
presence.update               // a contact came online or went offline
permission.request            // a permission request the user must decide
agent.status                  // my Agent is answering a contact: {conversation_id, state: thinking|tool|idle, tool?}; the client words it
conversation.updated          // a contact wrote in {conversation_id} (possibly a new thread); the client refreshes its list

message.new example:

{
  "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": "Use 0x03, Read Holding Registers…",
    "citations": [
      {
        "source": "X100 communications manual v3.2",
        "page": 87,
        "url": "https://acme.com/manuals/x100-v3.2.pdf#page=87",
        "trust_level": "authoritative"
      }
    ],
    "language": "en",
    "created_at": "2024-11-15T14:30:00Z"
  }
}

permission.request example:

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

There is no description in the payload, and that is deliberate. The server does not know what language the reader uses, so it sends structured facts only (action + the peer’s identity + the stored scope), and the sentence read at approval time is composed by the client from its i18n (packages/client/src/lib/permission-text.ts). This contract is owned outright by permissionRequestEventSchema in @confer/shared: the gateway parses with it before sending, the client parses with it on receipt.

Every row from GET /api/v1/permissions/pending has that same shape (plus a decision field) and comes from the same constructor, so a row obtained by polling and a row pushed over the socket match byte for byte.

SSE (LLM streaming)

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

Event types:

event: token
data: {"text":"Use "}

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 communications manual v3.2","page":87}

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

A2A API (outward-facing, called by other Confer instances)

See docs/03-protocol.md. Only the endpoints are listed here.

Two bindings live under the same prefix and pass through the same gates (a2a/inbound.ts); only the wire format differs.

The standard A2A HTTP+JSON binding (paths copied verbatim from §11.3 of the specification; this is the one the Agent Card advertises):

POST   /a2a/v1/message:send              # SendMessage → Task
GET    /a2a/v1/tasks/{id}                # GetTask
GET    /a2a/v1/tasks                     # ListTasks (cursor pagination)
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         # not implemented → UnsupportedOperation

Streaming and push. message:stream goes through the same validation and gates as message:send and answers text/event-stream, one data: line per StreamResponse: the {task} first, then one {statusUpdate} when the task finishes (or reaches AUTH_REQUIRED, waiting on a person), then the stream closes. An answer is one LLM turn with no partial output on this path, so the granularity is task events. :subscribe accepts GET and POST (the proto says GET, the spec’s tables POST) and answers UnsupportedOperation for a finished task. Push: a peer registers a webhook on a task (or passes configuration.taskPushNotificationConfig on message:send), and each state the task reaches is POSTed once as {task}, with Authorization: {scheme} {credentials} and the token in X-A2A-Notification-Token. The webhook must be https and resolve to a public address (the same SSRF rule as outbound A2A, checked again at delivery, redirects not followed); credentials are sealed and only scheme is echoed. At most 5 per task, visible to and deletable by only the peer that registered them.

Confer’s own dialect (instance to instance; discovered through /.well-known/agents.json):

POST   /a2a/v1/messages                  # receives messages from external Agents
GET    /a2a/v1/stream/{message_id}       # pulls the answer as a stream (SSE)
GET    /a2a/v1/agent-facts/{agent_did}   # public AgentFacts

Every A2A endpoint requires HTTP Message Signature verification, except agent-facts: like /.well-known/agents.json it is a public discovery document with the same visibility, so a non-public or disabled Agent always 404s. Its capabilities list only what the owner saved in the NANDA shape ({type, scope, languages}).

.well-known endpoints

GET    /.well-known/did.json                # the primary DID document
GET    /.well-known/agents.json             # every public Agent on this instance
GET    /.well-known/agent-card.json         # the standard A2A Agent Card (only when the instance has exactly one public Agent)
GET    /.well-known/openid-configuration    # future: OIDC compatibility (v2)

Standard A2A Agent Card (the interoperable discovery layer)

GET    /agents/{username}/agent-card.json   # that Agent's standard A2A Card
GET    /.well-known/agent-card.json         # the same, only when this instance has exactly one public Agent

Follows the AgentCard of the Linux Foundation’s Agent2Agent v1.0 (fields taken from specification/a2a.proto in a2aproject/A2A @ v1.0.1, under the proto3 JSON mapping, hence camelCase). The point is to let the A2A ecosystem discover this instance’s Agents: the names lined up but the protocols did not, because the other side’s discovery document lives at /.well-known/agent-card.json while this instance had only /.well-known/agents.json.

A few deliberate trade-offs:

Message layer (Task semantics)

POST /a2a/v1/message:send takes the spec’s SendMessageRequest and returns a Task. A task IS one inbound question: its id is that message’s id, its contextId is the conversation that archives it, and its state is derived from what happens next — no separate tasks table shadowing the same fact.

Confer’s asynchronous, consent-gated model lands exactly on the spec’s state machine:

Situation State
The Agent is answering TASK_STATE_WORKING
Answered TASK_STATE_COMPLETED
This turn cannot even start (no model configured, or the provider errored) TASK_STATE_FAILED
Suspended by the ask_user policy, awaiting the owner TASK_STATE_AUTH_REQUIRED (an interrupted state, not a terminal one)
The owner declined TASK_STATE_REJECTED

Two outcomes have no task to return, because no row was ever created: the unknown peer (held as a pending connection request) and an outright policy denial. Both answer 403 PERMISSION_DENIED and are told apart by ErrorInfo.metadata.confer_status — minting a task id that would 404 on the next call is worse.

The rest lines up with the spec point by point: the error body is google.rpc.Status-shaped and always carries ErrorInfo.reason (several A2A errors share one HTTP status code, and reason is the only field that separates them); a client that has not declared the required extension gets ExtensionSupportRequiredError per §3.3.4 rather than a 401 that explains nothing; historyLength=0 means omitting the field entirely, not sending an empty array; and nextPageToken is always present, an empty string when there is no next page.

Two deliberate deviations, both noted in the code: the blocking message:send wait is bounded (55s, after which the still-WORKING task is returned for the client to poll) — §3.2.2 offers no timeout exit, and an LLM call has no upper bound; and messageId idempotency (a MAY in §3.3.1) is not implemented, because a tenant-safe unique key needs owner scope, which the first message’s wire format does not carry.

Webhooks (optional, v1.5+)

Let external systems subscribe to events:

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

Supported events: message.new.peer, permission.granted, thread.archived.

Rate-limit policy

Route Limit
/api/v1/auth/login 10/minute per IP
/api/v1/auth/register 3/hour per IP
/api/v1/conversations/*/messages POST 60/minute per user
/a2a/v1/* 60/minute per IP and path, and 300/minute per IP across all of /a2a/v1
A2A questions (both bindings) Per peer DID per owner 20/minute and 300/day; per peer domain 60/minute. Over → 429 / RESOURCE_EXHAUSTED with Retry-After
/api/v1/consult/{peer} POST 30/minute per user
WSS at most 10 concurrent connections per user

Response when the limit is exceeded:

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

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

Consult API (user-initiated outbound A2A)

Lets the user (or an MCP server acting for them) put a question of their own to a peer agent that is already a contact, and collect the asynchronous reply afterwards. Signing and delivery happen entirely inside the gateway; the private key never leaves it.

How this differs from the “conversations API”: /api/v1/conversations + /api/v1/stream is talking to your own local LLM assistant; /api/v1/consult is what goes out over A2A to somebody else’s agent.

POST /api/v1/consult/:peerId

Starts or continues a type='consult' conversation (one per peer, reused), then signs and delivers a message.type='question'.

// request body (consultRequestSchema)
{ "question": "How do I rotate keys?", "code_context": "…optional code…", "language": "en" }
Response Meaning
201 { conversation_id, message_id, status: "sent" } signed and delivered
502 { ..., status: "failed", error } delivery failed (peer offline, no endpoint, or a signature problem)
403 not_a_contact the peer is not a contact of the current user

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

Long-polls for the peer’s asynchronous reply (it arrives on inbound /a2a/v1/messages carrying its thread_id, and the gateway hangs it back on the matching thread). wait is capped at 55s.

GET /api/v1/consult/:conversationId

Returns the full message history of that consult thread (200 messages at most).

Contract: inbound A2A triggers the local agent’s automatic reply only for message.type==='question'; answer and notification are merely stored and broadcast, so that a consult reply cannot set off an endless exchange.

← Back to Confer A2A · DID:web · RFC 9421 · NANDA