Traduzido do original em chinês. Havendo divergência, o original prevalece.

Confer — Especificação da API

Define todas as API entre o cliente e o servidor, e entre o servidor e os peers A2A.

Convenções gerais

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

Autenticação

API do cliente (usada pelo cliente do usuário)

Autenticação

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 requisição:

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

Resposta:

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

Início de sessão em dois passos (TOTP). Depois de ativado, login pede também, após uma palavra-passe correta, um totp_code (6 dígitos de uma app de autenticação) ou um recovery_code; sem nenhum responde 401 totp_required, que é como o cliente sabe mostrar o segundo passo, e um código errado dá 401 invalid_second_factor. Uma palavra-passe errada responde sempre primeiro invalid_credentials, por isso nunca revela se a conta tem dois passos. Cada código serve uma vez (o passo de tempo usado é registado) e cada código de recuperação é riscado ao ser usado; ambos são gastos por um único UPDATE condicional, pelo que dois inícios de sessão simultâneos não podem reutilizar um. O segredo de setup é guardado cifrado com AES-256-GCM e não tem efeito até enable confirmar um código da app; enable devolve esta única vez os 10 códigos de recuperação e só os seus hashes são guardados. disable pede a palavra-passe e um segundo fator. password pede a palavra-passe atual e termina todas as outras sessões. Tudo isto é auditado e limitado a 10 por minuto por conta. enable também pede password, para que um token roubado não chegue para pôr a app de autenticação de um atacante à frente da conta de outra pessoa; com os dois passos ativados, password também pede um segundo fator. Cinco códigos errados seguidos bloqueiam as tentativas de código dessa conta durante 15 minutos (429 too_many_attempts), além do limite de início de sessão por IP. Os códigos de recuperação são guardados como HMAC com a chave do servidor. Para um utilizador que perdeu a app de autenticação e os códigos de recuperação, um administrador pode desativá-lo com POST /api/v1/admin/users/{id}/reset-2fa (auditado, e nunca para a conta do próprio administrador).

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}

Tokens de API pessoais. Para scripts e ferramentas que chamam a API como o dono: Authorization: Bearer cfr_…, o mesmo cabeçalho que o access token de uma sessão usa. O token só é devolvido em claro quando é criado; o servidor guarda o seu SHA-256 e um prefixo para o reconhecer. Validade opcional de 1 a 365 dias, no máximo 20 por utilizador, e um token deixa de funcionar assim que o dono é desativado. Um token pode fazer o que o dono faz no chat, mas estas operações só aceitam uma sessão iniciada e respondem a um token com 403 session_required: terminar sessão, palavra-passe e início de sessão em dois passos, gerir tokens, decidir pedidos de permissão e cartões de errands, mudar chaves de LLM e todas as rotas de administração. Um token divulgado pode fazer os recados do dono, mas não decidir por ele nem trancá-lo fora. Criar e revogar fica auditado.

Configuração do usuário e do Agente

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      # se cada fornecedor está configurado (devolve apenas booleanos, nunca a chave)
PUT    /api/v1/agents/me/llm-keys      # guarda cifradas as chaves de API dos LLM
DELETE /api/v1/agents/me/llm-keys/{provider}
GET    /api/v1/agents/me/llm-keys/{provider}/models   # consulta ao vivo, no fornecedor, quais modelos ele oferece

Os valores de provider vêm do catálogo de fornecedores de @confer/shared (packages/shared/src/llm/catalog.ts), mais o serviço de ferramentas tavily. O catálogo é lido ao mesmo tempo pelo gateway, pelo agent-runtime e pelo cliente: a base URL, o caminho da lista de modelos e o modelo padrão só existem naquele único lugar, então acrescentar um fornecedor mexe apenas no catálogo.

/models repassa a lista de modelos do próprio fornecedor e nunca devolve uma lista mantida localmente:

{ "models": [{ "id": "gpt-4o" }] }
// uma lista vazia sempre traz o motivo; os quatro são distintos e cada um pede um remédio diferente
{ "models": [], "error": "no_key" }        // esse fornecedor ainda não tem chave configurada
{ "models": [], "error": "unauthorized" }  // o fornecedor recusou a chave (401/403)
{ "models": [], "error": "unreachable" }   // não foi possível alcançar o fornecedor, ou ele devolveu outro erro
{ "models": [], "error": "unsupported" }   // esse fornecedor não oferece endpoint de listagem de modelos

Contatos / Agentes peer

GET    /api/v1/contacts                     # lista os contatos. Paginação: ?limit=&offset=
POST   /api/v1/contacts                     # adiciona um contato
GET    /api/v1/contacts/{contact_id}        # detalhe de um contato (com o peer)
DELETE /api/v1/contacts/{contact_id}
PATCH  /api/v1/contacts/{contact_id}        # altera parcialmente alias / tags / pinned / muted (campos ausentes não são apagados)

POST   /api/v1/contacts/lookup              # busca por DID / domínio / nome de usuário

POST /api/v1/contacts/lookup requisição:

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

GET /api/v1/contacts devolve { contacts, total }. limit é 50 por padrão, com teto de 100, e offset é 0; a ordenação é por id (ULID) em ordem decrescente, ou seja, o mais recente primeiro — uma ordenação única e determinística é justamente o que impede a janela de offset de pular ou repetir linhas. total é a contagem completa, não a desta página, e é por ela que o cliente sabe que chegou ao fim. Um limit ou offset ilegível assume o valor padrão em vez de gerar erro.

Resposta: a lista de Agentes candidatos encontrados. A busca grava em peer_agents os peers que descobre e inclui em cada candidato o id local (peer_id) — e é exatamente esse id que POST /api/v1/contacts usa para adicionar o contato. POST /contacts é idempotente: adicionar o mesmo peer de novo devolve o contato já existente (200) em vez de erro.

Adicionar um contato é o consentimento com que quem recebe autoriza o outro a consumir o seu Agente: só um peer adicionado como contato pode fazer o meu Agente responder (e gastar o meu orçamento de LLM). Mensagens A2A de um peer não conectado ficam pendentes como um pedido de conexão a aprovar; veja «O portão do consentimento de conexão» em 03-protocol.md.

POST   /api/v1/contacts/{contact_id}/policies   # define as políticas permanentes (substituição integral, semântica de PUT)

O corpo de POST /contacts/{id}/policies tem a forma de execução { default?: 'allow'|'ask_user'|'deny', rules?: [{ action, peer_did?, decision }] } e é gravado inteiro em peer_contacts.policy_overrides_json. Semântica de mesclagem: ao decidir sobre uma requisição A2A de entrada, essa sobreposição por contato se soma à política do Agente — se contact.default estiver presente, substitui o padrão do Agente, e contact.rules vem antes das regras do Agente, portanto é avaliado primeiro (uma regra precisa por contato vence uma regra geral do Agente). Uma sobreposição vazia {} é a identidade: a decisão coincide byte a byte com a que se tomaria sem sobreposição.

Conversas

GET    /api/v1/conversations                       # lista as minhas conversas (para a tela inicial)
POST   /api/v1/conversations                       # cria uma conversa
GET    /api/v1/conversations/{id}
PATCH  /api/v1/conversations/{id}
DELETE /api/v1/conversations/{id}

GET    /api/v1/conversations/{id}/messages         # paginação: ?before=&limit=
POST   /api/v1/conversations/{id}/messages         # envia uma mensagem
GET    /api/v1/conversations/{id}/messages/{msg_id}/stream    # recebe por SSE a resposta do LLM em fluxo

POST   /api/v1/conversations/{id}/participants     # adiciona um participante
DELETE /api/v1/conversations/{id}/participants/{p_id}

POST   /api/v1/conversations/{id}/read             # marca como lida

GET lista por predefinição as conversas não arquivadas e, com ?archived=1, as arquivadas; cada uma traz unread_count, as mensagens que outros escreveram desde que o utilizador a marcou como lida pela última vez (ou desde que entrou, se nunca o fez) — as próprias não contam. PATCH aceita { name?, archived? } e só quem criou a conversa o pode usar. POST .../read define o last_read_at do utilizador para agora. Se um contacto escrever numa conversa arquivada, ela é desarquivada e passa para o topo.

GET /api/v1/search/messages?q=&limit= procura por subcadeia no texto das mensagens de todas as conversas em que o utilizador participa, arquivadas incluídas, das mais recentes para as mais antigas; q tem 1–200 caracteres e limit é 20 por predefinição, 50 no máximo. Devolve { results: [{ message_id, conversation_id, conversation_name, sender_type, content, created_at }] }. Usa-se correspondência por subcadeia e não a pesquisa de texto completo do Postgres, porque as configurações incluídas não segmentam chinês.

GET /api/v1/conversations/{id}/export devolve a conversa inteira, da mais antiga para a mais recente e com no máximo 5000 mensagens, apenas aos participantes: { conversation, messages, truncated }, cada mensagem com sender_type, sender_did, o sender_name do agente do peer, content, content_json e citations_json. Devolve apenas factos; o cliente redige o Markdown na língua de quem lê (quem é «eu» e quem é «o meu agente»).

POST /api/v1/conversations/{id}/messages requisição:

{
  "content_type": "text",
  "content": "Que código de função o registrador 0x40 do X100 usa?",
  "in_reply_to": null,
  "via": "web"
}

Resposta:

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

Gestão de permissões

GET    /api/v1/permissions/pending               # pedidos L2/L3 pendentes
POST   /api/v1/permissions/{id}/decide           # aprovar ou recusar
GET    /api/v1/permissions/history               # histórico

POST /api/v1/permissions/{id}/decide requisição:

{
  "decision": "allow_always",       // allow_once | allow_always | deny | deny_always
  "scope": "peer_action"            // alcance da decisão
}

Entre os pedidos pendentes, os de action='connect' são pedidos de conexão (gerados pela entrada A2A no primeiro contato de um peer desconhecido). Aprovar (allow_*) grava esse peer em peer_contacts e estabelece a conexão; recusar não estabelece.

Os de action='ask' são perguntas pendentes de um peer já conectado: a entrada A2A os gera quando a política do Agente decide ask_user para aquela pergunta (veja «Caixa de pendências (resposta em diferido)» em 03-protocol.md). Aprovar (allow_*) faz o Agente responder à pergunta suspensa; recusar a deixa sem resposta.

allow_always / deny_always são decisões permanentes. Numa pergunta pendente, gravam (ou substituem) uma regra { action: 'ask', decision: 'allow' | 'deny' } no início da política do contacto, para que as perguntas seguintes desse peer sejam respondidas ou recusadas sem ficarem pendentes; o dono pode ver e revogar a regra no painel de política do contacto. Num pedido de ligação, deny_always faz com que os pedidos seguintes desse peer sejam descartados em silêncio em vez de chegarem à caixa de entrada; adicionar o peer à mão pelo seu DID continua a funcionar.

GET /pending acompanha cada pedido de uma description (o pedido de conexão traz quem o iniciou e a primeira mensagem; a pergunta traz quem pergunta e o texto) para o dono poder decidir.

Memória de projeto (ligada à integração com o Claude Code)

GET    /api/v1/projects/{project_id}/peers              # os peers com memória neste projeto (name/did vindos do join)  ✅ implementado
POST   /api/v1/projects/{project_id}/peers              # registra explicitamente um peer no projeto   🔜 backlog
GET    /api/v1/projects/{project_id}/peers/{peer_id}/facts        # ✅ implementado
PUT    /api/v1/projects/{project_id}/peers/{peer_id}/facts        # ✅ implementado
GET    /api/v1/projects/{project_id}/peers/{peer_id}/decisions    # ✅ implementado
PUT    /api/v1/projects/{project_id}/peers/{peer_id}/decisions    # ✅ implementado

Notas de semântica (v0.1):

Base de conhecimento (RAG)

GET    /api/v1/knowledge-bases                                  # lista as minhas bases de conhecimento
POST   /api/v1/knowledge-bases                                  # cria uma
PATCH  /api/v1/knowledge-bases/{kb_id}                          # muda nome ou descrição, e se fica aberta a Agentes externos
DELETE /api/v1/knowledge-bases/{kb_id}                          # apaga junto com todos os seus documentos e vetores

GET    /api/v1/knowledge-bases/{kb_id}/documents                # paginação: ?limit=&offset=
POST   /api/v1/knowledge-bases/{kb_id}/documents                # envio multipart, campo chamado file
DELETE /api/v1/knowledge-bases/{kb_id}/documents/{doc_id}
POST   /api/v1/knowledge-bases/{kb_id}/documents/{doc_id}/retry # reindexa

O corpo de POST /knowledge-bases é { name, description? } (name de 1 a 255 caracteres) e a resposta é 201 com { knowledge_base }.

O corpo de PATCH /knowledge-bases/{kb_id} é { name?, description?, shared_with_peers? } e a resposta é { knowledge_base }. shared_with_peers só se muda aqui; na criação não é aceito: toda base nasce «só para mim», e abri-la ao exterior é um segundo gesto deliberado.

O que shared_with_peers decide é se uma pergunta A2A de entrada pode buscar nessa base, e o padrão é false. Não afeta o dono quando ele conversa na web: ele sempre busca em tudo. Essa fronteira tem de cair sobre o alcance da busca, e não sobre o prompt: a pergunta do outro e as instruções do dono chegam ao modelo como o mesmo tipo de texto, de modo que «deixar o Agente julgar o que pode contar» não constitui fronteira nenhuma. Pela mesma razão, uma pergunta A2A de entrada não recupera nenhuma memória de longo prazo: a memória de longo prazo é destilada das conversas do próprio dono, e nem uma de suas entradas foi marcada como apta a sair desta instância.

GET /knowledge-bases devolve { knowledge_bases } e não é paginado: as bases de um usuário são criadas à mão e o seu número é limitado.

GET /{kb_id}/documents devolve { documents, total }. limit é 50 por padrão, com teto de 100, e offset é 0; ordena-se por id (ULID) em ordem decrescente, ou seja, o mais recente primeiro — uma ordenação única e determinística é o que impede a janela de offset de pular ou repetir linhas. total é a contagem completa, não a desta página. Um limit ou offset ilegível assume o valor padrão em vez de gerar erro. Esta é a única lista desta seção que cresce sem limite, porque a base de conhecimento é justamente o destino dos envios.

O envio vai por multipart/form-data, o campo do arquivo chama-se sempre file e cada arquivo tem teto de 10 MB (acima disso, 400 bad_request). O Content-Type é o do formulário quando vem e, na falta, é deduzido da extensão. A resposta é 201 com { document }, e nesse momento status já vale processing: o fatiamento, a vetorização e a escrita no Qdrant acontecem de forma assíncrona depois da resposta, o endpoint de envio não os espera. O cliente então consulta a lista de documentos até status mudar.

Valores de status:

Valor Significado
processing Já gravado, sendo fatiado ou vetorizado. É o estado inicial após o envio e após um retry
ready Pode ser pesquisado. chunk_count é o número de fragmentos do documento
failed A indexação falhou (na análise, por falta de chave de embedding, ou na escrita no banco vetorial)

POST /{doc_id}/retry busca o arquivo original no armazenamento de objetos e reindexa; antes apaga os vetores que aquele documento já tinha, então não surgem fragmentos duplicados. Devolve 400 se o arquivo original já não existe (storage_key vazio) ou se o documento ainda está em processing. A resposta é { document }, com status de volta a processing e chunk_count zerado.

Apagar uma base de conhecimento apaga em cascata todas as suas linhas de documento e os vetores no Qdrant; apagar um único documento limpa ainda os seus vetores e o arquivo original no armazenamento de objetos. Uma falha na limpeza de vetores ou objetos não impede a exclusão no banco: melhor deixar um objeto órfão do que uma linha apontando para dados já apagados.

Todos os endpoints são limitados a user.sub: acessar a base ou o documento de outra pessoa devolve 404 (e não 403, para não revelar que existe).

O proxy reverso precisa deixar passar corpos de 10 MB. infra/nginx.conf define client_max_body_size 10m em /api/; com o padrão do nginx, 1 MB, arquivos de 1 a 10 MB nem chegam ao gateway e o navegador recebe a página 413 do próprio nginx.

Anexos

POST   /api/v1/attachments                       # multipart upload
GET    /api/v1/attachments/{id}/content          # o próprio ficheiro, para quem o carregou ou para os participantes da conversa da sua mensagem

Um carregamento começa sem vínculo; POST /conversations/{id}/messages reclama-o através de attachment_ids (no máximo 5), e só podem ser reclamados carregamentos do próprio remetente ainda não enviados. Uma mensagem com anexos e sem texto é válida. As imagens são reconhecidas pelos bytes (PNG / JPEG / GIF / WebP, até 5 MB cada) e chegam ao modelo como blocos de imagem; PDF / DOCX / XLSX / texto são lidos no carregamento (até 10 MB) e o texto acompanha a mensagem como <attachment> em cada turno. O SVG é tratado como texto e nunca é mostrado em linha como imagem. content só serve em linha uma imagem reconhecida; tudo o resto é uma transferência application/octet-stream. Cada mensagem da lista traz attachments. Apagar uma conversa apaga os anexos.

Uso

GET /api/v1/usage?month=YYYY-MM    # uso de tokens dos turnos do agente em um mês; por padrão, o mês atual

Retorna { month, rows }. Cada linha é um grupo provedor × modelo × audiência: turns, unreported (turnos cujo provedor não informou uso), failed, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens. Os meses são cortados em UTC; um month que não seja YYYY-MM retorna 400. Só os dados de quem chama são retornados.

WebSocket

Endpoint

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

A autenticação do handshake é idêntica à do REST, não um «a assinatura confere, pode passar»: typ tem de ser access, sid tem de apontar para uma sessão que ainda exista, e a conta não pode estar disabled. As três são indispensáveis: sem elas, basta a uma conta banida ter um token não expirado para reconectar e continuar recebendo mensagens, enquanto o próprio banimento (apagar todas as sessões) não revoga nada por esse caminho. Banir também fecha os sockets já abertos daquele usuário: o nginx dá a /ws um proxy_read_timeout de um dia, e barrar o próximo handshake não barra a conexão já estabelecida.

Nem o token verificado no handshake serve de aval ao socket para sempre: quando o token de acesso expira, o servidor fecha a conexão com 4001, e o cliente renova o token antes de reconectar. Sair da conta, ou a detecção de reutilização de um token de atualização, fecha com 1008 os sockets abertos sob a sessão que apaga.

Formato das mensagens

Todas as mensagens WS são JSON e trazem um campo type:

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

Cliente → servidor

ping                          // batimento
subscribe.conversation        // assinar uma conversa (o servidor confere a condição de participante)
unsubscribe.conversation
typing.start                  // só surte efeito em conversas já assinadas
typing.stop
read.ack                      // confirmação de leitura

A difusão de typing.* segue o conjunto de assinaturas daquele socket. Quando a assinatura tem portão e os eventos de digitação não têm, basta conhecer o id de uma conversa para injetar nela um «fulano está digitando» — e ainda com o próprio nome de usuário.

Servidor → cliente

pong
message.new                   // mensagem nova
message.updated               // reservado, ainda não enviado
message.deleted               // reservado, ainda não enviado
typing.update                 // quem está digitando
presence.update               // um contato entra ou sai
permission.request            // um pedido de permissão que o usuário precisa decidir
agent.status                  // o meu Agente está a responder a um contacto: {conversation_id, state: thinking|tool|idle, tool?}; o cliente redige o texto
conversation.updated          // um contacto escreveu em {conversation_id} (talvez um tópico novo); o cliente atualiza a lista

message.new por exemplo:

{
  "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": "Com 0x03, Read Holding Registers…",
    "citations": [
      {
        "source": "Manual de comunicação do X100 v3.2",
        "page": 87,
        "url": "https://acme.com/manuals/x100-v3.2.pdf#page=87",
        "trust_level": "authoritative"
      }
    ],
    "language": "pt",
    "created_at": "2024-11-15T14:30:00Z"
  }
}

permission.request por exemplo:

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

Não há description no payload, e isso é proposital. O servidor não sabe em que língua lê quem recebe, então manda apenas fatos estruturados (action + a identidade do peer + o scope armazenado), e a frase lida na aprovação é montada pelo cliente conforme o seu i18n (packages/client/src/lib/permission-text.ts). Esse contrato pertence exclusivamente ao permissionRequestEventSchema, em @confer/shared: o gateway usa-o para parsear antes de enviar e o cliente para parsear ao receber.

Cada linha de GET /api/v1/permissions/pending tem essa mesma forma (com um campo decision a mais) e sai do mesmo construtor, de modo que a linha obtida por polling e a empurrada pelo socket coincidem byte a byte.

SSE (LLM streaming)

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

Tipos de evento:

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

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

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

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

event: citation
data: {"source":"Manual de comunicação do X100 v3.2","page":87}

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

API A2A (para fora, chamada por outras instâncias do Confer)

Veja docs/03-protocol.md. Aqui só se listam os endpoints.

Dois bindings convivem sob o mesmo prefixo e compartilham os mesmos portões (a2a/inbound.ts); só o formato de fio difere.

Binding HTTP+JSON padrão do A2A (os caminhos são copiados tal e qual da §11.3 da especificação, e é este que o Agent Card anuncia):

POST   /a2a/v1/message:send              # SendMessage → Task
GET    /a2a/v1/tasks/{id}                # GetTask
GET    /a2a/v1/tasks                     # ListTasks (paginação por cursor)
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         # não implementado → UnsupportedOperation

Streaming e push. message:stream passa pelas mesmas validações e barreiras que message:send e responde text/event-stream, uma linha data: por StreamResponse: primeiro {task}, depois um {statusUpdate} quando a tarefa termina (ou chega a AUTH_REQUIRED, à espera de uma pessoa), e fecha. Neste caminho a resposta é um único turno do LLM sem saída parcial, por isso a granularidade é de eventos de tarefa. :subscribe aceita GET e POST (o proto diz GET, as tabelas da spec POST) e responde UnsupportedOperation a uma tarefa terminada. Push: um peer regista um webhook numa tarefa (ou envia configuration.taskPushNotificationConfig em message:send), e cada estado que a tarefa atinge é enviado uma vez por POST como {task}, com Authorization: {scheme} {credentials} e o token em X-A2A-Notification-Token. O webhook tem de ser https e resolver para um endereço público (a mesma regra SSRF do A2A de saída, verificada de novo na entrega, sem seguir redirecionamentos); as credenciais ficam seladas e só o scheme é devolvido. No máximo 5 por tarefa, visíveis e apagáveis só pelo peer que os registou.

O dialeto próprio do Confer (entre instâncias; descoberto por /.well-known/agents.json):

POST   /a2a/v1/messages                  # recebe mensagens de Agentes externos
GET    /a2a/v1/stream/{message_id}       # puxa a resposta em fluxo (SSE)
GET    /a2a/v1/agent-facts/{agent_did}   # AgentFacts público

Todos os endpoints A2A exigem verificação da assinatura HTTP da mensagem, exceto agent-facts: assim como /.well-known/agents.json, é um documento público de descoberta com a mesma visibilidade, então um Agente não público ou desativado devolve sempre 404. Suas capabilities trazem apenas o que o dono salvou no formato NANDA ({type, scope, languages}).

.well-known endpoints

GET    /.well-known/did.json                # documento DID principal
GET    /.well-known/agents.json             # lista de todos os Agentes públicos desta instância
GET    /.well-known/agent-card.json         # Agent Card padrão do A2A (apenas quando a instância tem um único Agente público)
GET    /.well-known/openid-configuration    # no futuro: compatibilidade OIDC (v2)

Agent Card padrão do A2A (camada de descoberta interoperável)

GET    /agents/{username}/agent-card.json   # o Card padrão A2A daquele Agente
GET    /.well-known/agent-card.json         # o mesmo, apenas quando esta instância tem um único Agente público

Segue o AgentCard do Agent2Agent v1.0 da Linux Foundation (campos tirados de specification/a2a.proto de a2aproject/A2A @ v1.0.1, com mapeamento JSON do proto3, daí o camelCase). O objetivo é que o ecossistema A2A descubra os Agentes desta instância: os nomes batiam, mas os protocolos não se entendiam, porque o documento de descoberta do outro lado fica em /.well-known/agent-card.json e esta instância só tinha /.well-known/agents.json.

Algumas escolhas deliberadas:

Camada de mensagens (semântica de Task)

POST /a2a/v1/message:send recebe o SendMessageRequest da especificação e devolve um Task. Uma tarefa é uma pergunta de entrada: o seu id é o id daquela mensagem, o contextId é a conversa que a arquiva, e o estado se deduz do que acontece depois — não há uma tabela tasks à parte espelhando o mesmo fato.

O modelo assíncrono do Confer, com o seu portão de consentimento, cai exatamente na máquina de estados da especificação:

Situação Estado
O Agente está respondendo TASK_STATE_WORKING
Terminou TASK_STATE_COMPLETED
Este turno não consegue nem começar (sem modelo configurado, ou o fornecedor falhou) TASK_STATE_FAILED
Suspenso pela política ask_user, à espera do dono TASK_STATE_AUTH_REQUIRED (estado de interrupção, não terminal)
O dono recusou TASK_STATE_REJECTED

Há dois casos em que não existe tarefa a devolver, porque nenhuma linha chegou a ser criada: o peer desconhecido (que fica como pedido de conexão pendente) e a recusa direta por política. Ambos respondem 403 PERMISSION_DENIED e se distinguem por ErrorInfo.metadata.confer_status — inventar um id de tarefa que daria 404 na chamada seguinte seria pior.

O resto do comportamento acompanha a especificação ponto a ponto: o corpo de erro tem a forma de google.rpc.Status e traz sempre ErrorInfo.reason (vários erros A2A compartilham o mesmo código HTTP, e reason é o único campo que os distingue); a um cliente que não declarou a extensão obrigatória responde-se ExtensionSupportRequiredError, conforme a §3.3.4, e não um 401 que nada explica; historyLength=0 significa omitir o campo inteiro, não mandar um array vazio; e nextPageToken está sempre presente, com string vazia quando não há página seguinte.

Dois desvios deliberados, ambos anotados no código: a espera do message:send bloqueante tem teto (55 s, após os quais se devolve a tarefa ainda em WORKING para o cliente consultar) — a §3.2.2 não oferece saída por tempo, e uma chamada a um LLM não tem cota superior; e a idempotência por messageId (um MAY da §3.3.1) não foi feita, porque uma chave única segura entre inquilinos precisa do escopo do dono, que o formato de fio da primeira mensagem não traz.

Webhooks (opcional, v1.5+)

Permitem que sistemas externos assinem eventos:

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

Eventos suportados: message.new.peer, permission.granted, thread.archived.

Política de limite de taxa

Rota Limite
/api/v1/auth/login 10/minuto por IP
/api/v1/auth/register 3/hora por IP
/api/v1/conversations/*/messages POST 60/minuto por usuário
/a2a/v1/* 60/minuto por IP e caminho, e 300/minuto por IP em todo o /a2a/v1
Perguntas A2A (ambos os bindings) Por DID de peer e dono 20/minuto e 300/dia; por domínio do peer 60/minuto. Excedido → 429 / RESOURCE_EXHAUSTED com Retry-After
/api/v1/consult/{peer} POST 30/minuto por utilizador
WSS no máximo 10 conexões simultâneas por usuário

Resposta ao exceder o limite:

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

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

API de consulta (saída A2A iniciada pelo usuário)

Permite que o usuário (ou o servidor MCP que age por ele) pergunte por iniciativa própria a um Agente peer que já seja seu contato e depois recolha a resposta assíncrona. A assinatura e a entrega acontecem inteiramente dentro do gateway; a chave privada não sai dele.

Diferença para a «API de conversas»: /api/v1/conversations + /api/v1/stream é conversar com o próprio assistente LLM local; /api/v1/consult é o que vai por A2A para o Agente de outra pessoa.

POST /api/v1/consult/:peerId

Abre ou continua uma conversa de type='consult' (uma por peer, reaproveitada), e assina e entrega um message.type='question'.

// corpo da requisição (consultRequestSchema)
{ "question": "Como se faz a rotação de chaves?", "code_context": "…código opcional…", "language": "pt" }
Resposta Significado
201 { conversation_id, message_id, status: "sent" } assinada e entregue
502 { ..., status: "failed", error } a entrega falhou (peer off-line, sem endpoint, ou problema de assinatura)
403 not_a_contact o peer não é contato do usuário atual

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

Espera por long polling a resposta assíncrona do peer (que chega pela entrada /a2a/v1/messages com o seu thread_id, e o gateway a pendura de volta no fio correspondente). O teto de wait é 55 s.

GET /api/v1/consult/:conversationId

Devolve o histórico completo daquele fio de consulta (no máximo 200 mensagens).

Contrato: a entrada A2A só dispara a resposta automática do Agente local para message.type==='question'; answer e notification apenas são gravados e difundidos, para que a resposta de uma consulta não desencadeie uma troca sem fim.

← Voltar ao Confer A2A · DID:web · RFC 9421 · NANDA