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
- Base URL:
https://{instance}/api - Codificação: JSON, UTF-8
- Formato de data e hora: ISO 8601, UTC (
2024-11-15T14:30:00Z) - Identificadores: ULID (
01HXKQ7Z2N3M4P5R6T7Y8Z9A0B) - Formato de erro:
{
"error": {
"code": "invalid_request",
"message": "Human-readable message",
"details": { /* optional */ }
}
}
Autenticação
- Cliente do usuário:
Authorization: Bearer <jwt_access_token> - TTL do access token: 15 minutos; TTL do refresh token: 90 dias
- Os dois tokens se distinguem pela claim
typ(access/refresh) e não são intercambiáveis: o cabeçalhoAuthorizationsó aceitaaccess, ePOST /auth/refreshsó aceitarefresh. Antes eles diferiam apenas noexp, de modo que o refresh token era um passe de 90 dias em qualquer endpoint autenticado e os 15 minutos do access token não valiam nada - O refresh rotaciona a cada uso e é conferido contra
sessions.refresh_token_hash; se não bater, trata-se de reuso e a sessão inteira é invalidada.sessions.expires_até o limite absoluto da sessão: a rotação não o estende - Os tokens ficam no armazenamento local do cliente, não em um cookie HTTP-only (o cliente é um aplicativo de desktop Tauri, onde não existe equivalente aos cookies de mesma origem)
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):
- Todas as consultas são limitadas a
user.sub(isolamento entre usuários). - Antes de um PUT verifica-se que o peer é contato daquele usuário (
peer_contacts); se não for, devolve-se403 not_a_contact. - O PUT faz upsert: a primeira escrita deixa
version=1, e cada seguinte incrementaversione atualizaupdated_at.factsedecisionssão independentes — escrever uma seção não apaga a outra. GET facts/decisionsdevolve200, string vazia eversion:0quando aquele par (projeto, peer) ainda não tem memória (não um 404: «este peer ainda não deixou nada» é um estado normal na leitura).project_idé validado por^[a-zA-Z0-9._\-/]+$(1 a 255 caracteres); se não passar, devolve-se400 invalid_project_id.GET peersdevolve um array vazio num projeto sem nada. A relação (projeto, peer) nasce implicitamente do PUT de facts/decisions (nesta fase não há registro explícito porPOST peers).
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.confdefineclient_max_body_size 10mem/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.
- Os totais de tokens deixam de fora os turnos
unreported; se nenhum turno de um grupo informou uso, os campos sãonull: não informado não é zero. - Os
cache_*fazem parte deinput_tokens, não se somam a eles; sãonullquando o provedor nunca mencionou cache. - Linhas com
audience: peersão turnos respondendo a um contato conectado, pagos igualmente pelo dono. - Só turnos do agente são registrados (o chat web e o A2A de entrada), uma linha por turno, inclusive os que falharam. Extração de memória e chamadas de embedding não.
- Sem valores em dinheiro: os preços por modelo não são mantidos localmente, e o custo está na fatura do provedor.
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:
- Um Card por Agente, com
supportedInterfaces[].tenant= nome de usuário. O well-known da especificação pressupõe um Agente por domínio, e esta instância é multi-inquilino;tenanté precisamente o seletor de roteamento que a especificação define para «vários Agentes atrás de um mesmo endpoint A2A»./.well-known/agent-card.jsonsó responde quando há exatamente um Agente público (o caso de quem se auto-hospeda sozinho); caso contrário devolve 404 e aponta paraagents.jsonna mensagem de erro — escolher uma conta qualquer e chamá-la de «o Agente deste domínio» seria falso. streaming: true,pushNotifications: true.message:stream,:subscribee as rotas de configuração de push respondem nas formas da spec; o streaming é ao nível de eventos de tarefa (a Task e depois um estado final).- Não se declara
securitySchemes. O que a especificação oferece ali é chave de API, autenticação HTTP, OAuth2, OIDC ou mTLS, e este endpoint não aceita nenhum: o que ele quer é uma requisição assinada. Preencher um qualquer equivaleria a dizer ao cliente que ele pode se autenticar de um modo que será infalivelmente recusado. A exigência real é declarada como extensão obrigatória (capabilities.extensions, com o endereço da RFC 9421 emurierequired: true), que é exatamente o mecanismo que a especificação prevê para isso. -
O Card é um documento de descoberta e a sua visibilidade é idêntica à de
/.well-known/agents.json: um Agente não público ou desativado devolve sempre 404; do contrário esta rota viraria um jeito de enumerar contas que o dono não quis tornar públicas. - Anuncia-se um único binding. O dialeto próprio do Confer vive nesta mesma URL, mas não entra no Card: a §5.1 exige que todos os bindings declarados por um Agente sejam funcionalmente equivalentes, e o dialeto não tem ciclo de vida de tarefas. Ele é descoberto por
/.well-known/agents.json, e assim o Card não promete nada que não possa cumprir.
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.
200 { status: "answered", message }— a resposta chegou200 { status: "pending" }— o tempo esgotou sem resposta; pode-se consultar de novo mais tarde
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';answerenotificationapenas são gravados e difundidos, para que a resposta de uma consulta não desencadeie uma troca sem fim.