Traducido del original en chino. Si ambos difieren, el original prevalece.

Confer — Especificación de la API

Define todas las API entre el cliente y el servidor, y entre el servidor y los peers A2A.

Convenciones generales

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

Autenticación

API de cliente (la que usa el cliente de usuario)

Autenticación

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 petición:

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

Respuesta:

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

Inicio de sesión en dos pasos (TOTP). Una vez activado, login pide además, tras una contraseña correcta, un totp_code (6 dígitos de una app de autenticación) o un recovery_code; sin ninguno responde 401 totp_required, que es como el cliente sabe que debe mostrar el segundo paso, y uno incorrecto es 401 invalid_second_factor. Una contraseña incorrecta siempre responde antes invalid_credentials, así que nunca revela si la cuenta tiene dos pasos. Cada código sirve una vez (se registra el paso de tiempo usado) y cada código de recuperación se tacha al usarse; ambos se gastan con un único UPDATE condicional, de modo que dos inicios de sesión simultáneos no pueden reutilizar uno. El secreto de setup se guarda cifrado con AES-256-GCM y no tiene efecto hasta que enable confirma un código de la app; enable devuelve esta única vez los 10 códigos de recuperación y solo se guardan sus hashes. disable pide la contraseña y un segundo factor. password pide la contraseña actual y cierra todas las demás sesiones. Todo ello se audita y se limita a 10 por minuto por cuenta. enable también pide password, de modo que un token robado por sí solo no puede poner la app de autenticación de un atacante delante de la cuenta de otra persona; con los dos pasos activados, password también pide un segundo factor. Cinco códigos incorrectos seguidos bloquean los intentos de código de esa cuenta durante 15 minutos (429 too_many_attempts), además del límite de inicio de sesión por IP. Los códigos de recuperación se guardan como HMAC con la clave del servidor. Para un usuario que ha perdido su app de autenticación y sus códigos de recuperación, un administrador puede desactivarlo con POST /api/v1/admin/users/{id}/reset-2fa (se audita y nunca sirve para la propia cuenta del 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 personales. Para scripts y herramientas que llaman a la API como el dueño: Authorization: Bearer cfr_…, la misma cabecera que usa el access token de una sesión. El token solo se devuelve en claro al crearlo; el servidor guarda su SHA-256 y un prefijo para reconocerlo. Caducidad opcional de 1 a 365 días, como máximo 20 por usuario, y un token deja de funcionar en cuanto se desactiva a su dueño. Un token puede hacer lo que el dueño hace en el chat, pero estas operaciones solo aceptan una sesión iniciada y responden a un token con 403 session_required: cerrar sesión, contraseña e inicio de sesión en dos pasos, gestionar tokens, decidir solicitudes de permiso y tarjetas de errands, cambiar claves de LLM y todas las rutas de administración. Un token filtrado puede hacer los recados del dueño, pero no decidir por él ni dejarlo fuera. Crear y revocar quedan auditados.

Configuración del usuario y del 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      # si cada proveedor está configurado (devuelve solo booleanos, nunca la clave)
PUT    /api/v1/agents/me/llm-keys      # guarda cifradas las claves de API de los LLM
DELETE /api/v1/agents/me/llm-keys/{provider}
GET    /api/v1/agents/me/llm-keys/{provider}/models   # consulta al proveedor, en vivo, qué modelos ofrece

Los valores de provider salen del catálogo de proveedores de @confer/shared (packages/shared/src/llm/catalog.ts), más el servicio de herramientas tavily. El catálogo lo leen a la vez la pasarela, agent-runtime y el cliente: la base URL, la ruta de la lista de modelos y el modelo por defecto se escriben en ese único sitio, así que añadir un proveedor es tocar solo el catálogo.

/models reenvía la lista de modelos del propio proveedor y nunca devuelve una lista mantenida localmente:

{ "models": [{ "id": "gpt-4o" }] }
// una lista vacía siempre lleva su motivo; los cuatro son distintos y cada uno pide un remedio distinto
{ "models": [], "error": "no_key" }        // ese proveedor aún no tiene clave configurada
{ "models": [], "error": "unauthorized" }  // el proveedor rechazó la clave (401/403)
{ "models": [], "error": "unreachable" }   // no se puede contactar con el proveedor, o devolvió otro error
{ "models": [], "error": "unsupported" }   // ese proveedor no ofrece un endpoint de lista de modelos

Contactos / Agentes ajenos

GET    /api/v1/contacts                     # lista los contactos. Paginación: ?limit=&offset=
POST   /api/v1/contacts                     # añade un contacto
GET    /api/v1/contacts/{contact_id}        # detalle de un contacto (con su peer)
DELETE /api/v1/contacts/{contact_id}
PATCH  /api/v1/contacts/{contact_id}        # modifica parcialmente alias / tags / pinned / muted (los campos ausentes no se borran)

POST   /api/v1/contacts/lookup              # busca por DID / dominio / nombre de usuario

POST /api/v1/contacts/lookup petición:

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

GET /api/v1/contacts devuelve { contacts, total }. limit vale 50 por defecto con un máximo de 100, y offset vale 0; se ordena por id (ULID) de forma descendente, o sea lo más reciente primero — el orden es único y determinista, que es lo que impide que la ventana de offset se salte filas o las repita. total es el recuento completo, no el de esta página, y con él el cliente sabe que ha llegado al final. Un limit o un offset que no se puedan interpretar toman el valor por defecto en lugar de dar error.

Respuesta: la lista de Agentes candidatos encontrados. La búsqueda guarda en peer_agents los peers que descubre e incluye en cada candidato su id local (peer_id) — y es justamente ese id el que usa POST /api/v1/contacts para añadir el contacto. POST /contacts es idempotente: añadir dos veces el mismo peer devuelve el contacto que ya existe (200) en lugar de un error.

Añadir un contacto es el consentimiento con el que quien recibe autoriza al otro a consumir su Agente: solo un peer añadido como contacto puede hacer que mi Agente responda (y gaste mi presupuesto de LLM). Los mensajes A2A de un peer no conectado quedan en suspenso como una solicitud de conexión pendiente de aprobación; véase «La puerta del consentimiento de conexión» en 03-protocol.md.

POST   /api/v1/contacts/{contact_id}/policies   # fija las políticas permanentes (sustitución completa, semántica de PUT)

El cuerpo de POST /contacts/{id}/policies tiene la forma de tiempo de ejecución { default?: 'allow'|'ask_user'|'deny', rules?: [{ action, peer_did?, decision }] } y se escribe entero en peer_contacts.policy_overrides_json. Semántica de fusión: al decidir sobre una petición A2A entrante, esta anulación por contacto se superpone a la política del Agente — si contact.default está presente sustituye al valor por defecto del Agente, y contact.rules va delante de las reglas del Agente, así que se evalúa primero (una regla precisa por contacto gana a una regla general del Agente). Una anulación vacía {} es la identidad: la decisión coincide byte a byte con la que se tomaría sin anulación.

Conversaciones

GET    /api/v1/conversations                       # lista mis conversaciones (para la pantalla de inicio)
POST   /api/v1/conversations                       # crea una conversación
GET    /api/v1/conversations/{id}
PATCH  /api/v1/conversations/{id}
DELETE /api/v1/conversations/{id}

GET    /api/v1/conversations/{id}/messages         # paginación: ?before=&limit=
POST   /api/v1/conversations/{id}/messages         # envía un mensaje
GET    /api/v1/conversations/{id}/messages/{msg_id}/stream    # recibe por SSE la respuesta del LLM en flujo

POST   /api/v1/conversations/{id}/participants     # añade un participante
DELETE /api/v1/conversations/{id}/participants/{p_id}

POST   /api/v1/conversations/{id}/read             # marca como leído

GET lista por defecto las conversaciones no archivadas y, con ?archived=1, las archivadas; cada una lleva unread_count, los mensajes que otros escribieron desde que el usuario la marcó como leída por última vez (o desde que se unió, si nunca lo hizo); los propios no cuentan. PATCH acepta { name?, archived? } y solo puede usarlo quien creó la conversación. POST .../read fija el last_read_at del usuario en este momento. Si un contacto escribe en una conversación archivada, se desarchiva y pasa a la parte superior.

GET /api/v1/search/messages?q=&limit= busca por subcadena en el texto de los mensajes de todas las conversaciones en las que participa el usuario, archivadas incluidas, de la más reciente a la más antigua; q tiene 1–200 caracteres y limit vale 20 por defecto, 50 como máximo. Devuelve { results: [{ message_id, conversation_id, conversation_name, sender_type, content, created_at }] }. Se usa coincidencia por subcadena y no la búsqueda de texto completo de Postgres porque sus configuraciones integradas no segmentan el chino.

GET /api/v1/conversations/{id}/export devuelve la conversación completa, de la más antigua a la más reciente y con un máximo de 5000 mensajes, solo a sus participantes: { conversation, messages, truncated }; cada mensaje lleva sender_type, sender_did, el sender_name del agente del peer, content, content_json y citations_json. Solo devuelve hechos; el cliente redacta el Markdown en el idioma del lector (quién es «tú» y quién «tu agente»).

POST /api/v1/conversations/{id}/messages petición:

{
  "content_type": "text",
  "content": "¿Qué código de función usa el registro 0x40 del X100?",
  "in_reply_to": null,
  "via": "web"
}

Respuesta:

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

Gestión de permisos

GET    /api/v1/permissions/pending               # peticiones L2/L3 pendientes
POST   /api/v1/permissions/{id}/decide           # aprobar o rechazar
GET    /api/v1/permissions/history               # historial

POST /api/v1/permissions/{id}/decide petición:

{
  "decision": "allow_always",       // allow_once | allow_always | deny | deny_always
  "scope": "peer_action"            // alcance de la decisión
}

Entre las peticiones pendientes, las que tienen action='connect' son solicitudes de conexión (las genera la entrada A2A cuando un peer desconocido contacta por primera vez). Aprobarla (allow_*) escribe ese peer en peer_contacts y establece la conexión; rechazarla no.

Las que tienen action='ask' son preguntas pendientes de un peer ya conectado: las genera la entrada A2A cuando la política del Agente resuelve ask_user para esa pregunta (véase «Bandeja pendiente (respuesta en diferido)» en 03-protocol.md). Aprobarla (allow_*) hace que el Agente responda a la pregunta en suspenso; rechazarla la deja sin responder.

allow_always / deny_always son decisiones permanentes. Sobre una pregunta en suspenso escriben (o sustituyen) una regla { action: 'ask', decision: 'allow' | 'deny' } al principio de la política del contacto, de modo que las siguientes preguntas de ese peer se responden o se rechazan sin quedar en suspenso; el dueño puede ver y revocar la regla en el panel de política del contacto. Sobre una solicitud de conexión, deny_always hace que las siguientes solicitudes de ese peer se descarten en silencio en lugar de llegar a la bandeja; añadir el peer a mano por su DID sigue funcionando.

GET /pending acompaña cada petición de una description (la solicitud de conexión lleva quién la inicia y su primer mensaje; la pregunta, quién la hace y su texto) para que el dueño pueda decidir.

Memoria de proyecto (para la integración con Claude Code)

GET    /api/v1/projects/{project_id}/peers              # los peers con memoria en este proyecto (con name/did en el join)  ✅ implementado
POST   /api/v1/projects/{project_id}/peers              # registra un peer en el proyecto explícitamente   🔜 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 conocimiento (RAG)

GET    /api/v1/knowledge-bases                                  # lista mis bases de conocimiento
POST   /api/v1/knowledge-bases                                  # crea una
PATCH  /api/v1/knowledge-bases/{kb_id}                          # cambia el nombre o la descripción, y si se abre a Agentes externos
DELETE /api/v1/knowledge-bases/{kb_id}                          # la borra junto con todos sus documentos y vectores

GET    /api/v1/knowledge-bases/{kb_id}/documents                # paginación: ?limit=&offset=
POST   /api/v1/knowledge-bases/{kb_id}/documents                # subida multipart, campo llamado file
DELETE /api/v1/knowledge-bases/{kb_id}/documents/{doc_id}
POST   /api/v1/knowledge-bases/{kb_id}/documents/{doc_id}/retry # vuelve a indexar

El cuerpo de POST /knowledge-bases es { name, description? } (name de 1 a 255 caracteres) y devuelve 201 con { knowledge_base }.

El cuerpo de PATCH /knowledge-bases/{kb_id} es { name?, description?, shared_with_peers? } y devuelve { knowledge_base }. shared_with_peers solo se puede cambiar aquí; al crear la base no se acepta: toda base nace «solo para mí», y abrirla al exterior es un segundo acto deliberado.

Lo que decide shared_with_peers es si una pregunta A2A entrante puede buscar en esa base, y vale false por defecto. No afecta al dueño cuando conversa en la web: él siempre lo busca todo. Esta frontera tiene que caer sobre el alcance de la búsqueda y no sobre el prompt: la pregunta del otro y las instrucciones del dueño le llegan al modelo como el mismo tipo de texto, así que «que el Agente juzgue si debe contarlo» no constituye frontera alguna. Por lo mismo, una pregunta A2A entrante no recupera ninguna memoria de largo plazo: la memoria de largo plazo se destila de las conversaciones del propio dueño, y ni una sola de sus entradas está marcada como apta para salir de esta instancia.

GET /knowledge-bases devuelve { knowledge_bases } y no se pagina: las bases de un usuario se crean a mano y su número está acotado.

GET /{kb_id}/documents devuelve { documents, total }. limit vale 50 por defecto con un máximo de 100, y offset vale 0; se ordena por id (ULID) de forma descendente, o sea lo más reciente primero — un orden único y determinista es lo que impide que la ventana de offset se salte filas o las repita. total es el recuento completo, no el de esta página. Un limit o un offset ilegibles toman el valor por defecto en lugar de dar error. Esta es la única lista de esta sección que crece sin límite, porque la base de conocimiento es justamente el destino de las subidas.

La subida va por multipart/form-data, el campo del fichero se llama siempre file y cada fichero puede pesar como mucho 10 MB (si se pasa, 400 bad_request). El Content-Type se toma del formulario si viene y, si falta, se deduce de la extensión. La respuesta es 201 con { document }, y para entonces status ya vale processing: la segmentación, la vectorización y la escritura en Qdrant ocurren de forma asíncrona después de la respuesta, el endpoint de subida no las espera. El cliente sondea la lista de documentos hasta que status cambie.

Valores de status:

Valor Significado
processing Ya está guardado y se está segmentando o vectorizando. Es el estado inicial tras subir y tras un retry
ready Se puede buscar en él. chunk_count es el número de fragmentos del documento
failed La indexación falló (al analizarlo, por falta de clave de embedding o al escribir en la base vectorial)

POST /{doc_id}/retry recupera el fichero original del almacenamiento de objetos y lo vuelve a indexar; antes borra los vectores que ese documento ya tenía, así que no se duplican fragmentos. Devuelve 400 si el fichero original ya no está (storage_key vacío) o si el documento sigue en processing. La respuesta es { document }, con status de vuelta a processing y chunk_count a cero.

Borrar una base de conocimiento borra en cascada todas sus filas de documento y sus vectores en Qdrant; borrar un solo documento limpia además sus vectores y el fichero original en el almacenamiento de objetos. Que falle la limpieza de vectores o de objetos no impide el borrado en la base de datos: mejor dejar un objeto huérfano que una fila que apunta a datos ya borrados.

Todos los endpoints se limitan a user.sub: acceder a la base o al documento de otra persona devuelve 404 (y no 403, para no revelar que existe).

El proxy inverso tiene que dejar pasar cuerpos de 10 MB. infra/nginx.conf fija client_max_body_size 10m en /api/; con el valor por defecto de nginx, 1 MB, los ficheros de entre 1 y 10 MB no llegan siquiera a la pasarela y lo que ve el navegador es la página 413 de nginx.

Adjuntos

POST   /api/v1/attachments                       # multipart upload
GET    /api/v1/attachments/{id}/content          # el archivo, para quien lo subió o para los participantes de la conversación de su mensaje

Una subida empieza sin vincular; POST /conversations/{id}/messages la reclama mediante attachment_ids (como máximo 5), y solo se pueden reclamar subidas propias aún no enviadas. Un mensaje con adjuntos y sin texto es válido. Las imágenes se reconocen por sus bytes (PNG / JPEG / GIF / WebP, hasta 5 MB cada una) y llegan al modelo como bloques de imagen; PDF / DOCX / XLSX / texto se leen al subirlos (hasta 10 MB) y su texto acompaña al mensaje como <attachment> en cada turno. SVG se trata como texto y nunca se muestra en línea como imagen. content solo sirve en línea una imagen reconocida; todo lo demás se descarga como application/octet-stream. Cada mensaje de la lista lleva attachments. Borrar una conversación borra sus adjuntos.

Uso

GET /api/v1/usage?month=YYYY-MM    # uso de tokens de los turnos del agente en un mes; por defecto, el mes actual

Devuelve { month, rows }. Cada fila es un grupo proveedor × modelo × audiencia: turns, unreported (turnos cuyo proveedor no informó uso), failed, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens. Los meses se cortan en UTC; un month que no sea YYYY-MM devuelve 400. Solo se devuelven los datos de quien llama.

WebSocket

Endpoint

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

La autenticación del handshake es idéntica a la de REST, no un «si la firma vale, que pase»: typ tiene que ser access, sid tiene que apuntar a una sesión que siga existiendo y la cuenta no puede estar disabled. Las tres cosas son imprescindibles: sin ellas, a una cuenta bloqueada le basta con que su token no haya caducado para reconectarse y seguir recibiendo mensajes, mientras que el bloqueo en sí (borrar todas sus sesiones) no revoca nada por esta vía. Bloquear a alguien cierra también los sockets que ya tuviera abiertos: nginx da a /ws un proxy_read_timeout de un día, y detener el siguiente handshake no detiene la conexión ya establecida.

Tampoco el token comprobado en el handshake avala el socket para siempre: cuando el token de acceso caduca, el servidor cierra la conexión con 4001 y el cliente renueva su token antes de reconectar. Cerrar sesión, o que se detecte la reutilización de un token de refresco, cierra con 1008 los sockets abiertos bajo la sesión que se borra.

Formato de los mensajes

Todos los mensajes WS son JSON y llevan un campo type:

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

Cliente → servidor

ping                          // latido
subscribe.conversation        // suscribirse a una conversación (el servidor comprueba que se es participante)
unsubscribe.conversation
typing.start                  // solo surte efecto en conversaciones ya suscritas
typing.stop
read.ack                      // confirmación de lectura

La difusión de typing.* se rige por el conjunto de suscripciones de ese socket. Cuando la suscripción tiene puerta y los eventos de escritura no, basta con conocer el id de una conversación para inyectar en ella un «fulano está escribiendo» — con el nombre de usuario de uno mismo, además.

Servidor → cliente

pong
message.new                   // mensaje nuevo
message.updated               // reservado, aún no se envía
message.deleted               // reservado, aún no se envía
typing.update                 // quién está escribiendo
presence.update               // un contacto se conecta o se desconecta
permission.request            // una petición de permiso que el usuario debe decidir
agent.status                  // mi Agente está respondiendo a un contacto: {conversation_id, state: thinking|tool|idle, tool?}; el cliente lo redacta
conversation.updated          // un contacto escribió en {conversation_id} (quizá un hilo nuevo); el cliente refresca su lista

message.new por ejemplo:

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

permission.request por ejemplo:

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

En la carga útil no hay description, y es a propósito. El servidor no sabe en qué idioma lee quien recibe, así que solo envía hechos estructurados (action + la identidad del peer + el scope almacenado), y la frase que se lee al aprobar la compone el cliente según su i18n (packages/client/src/lib/permission-text.ts). Este contrato pertenece en exclusiva a permissionRequestEventSchema, en @confer/shared: la pasarela lo usa para parsear antes de enviar y el cliente para parsear al recibir.

Cada fila de GET /api/v1/permissions/pending tiene esa misma forma (con un campo decision de más) y sale del mismo constructor, de modo que la fila que llega por sondeo y la que empuja el socket coinciden 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":"Con "}

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 comunicaciones del X100 v3.2","page":87}

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

API A2A (hacia fuera, para que la llamen otras instancias de Confer)

Véase docs/03-protocol.md. Aquí solo se enumeran los endpoints.

Dos vinculaciones conviven bajo el mismo prefijo y comparten las mismas puertas (a2a/inbound.ts); lo único que cambia es el formato de cable.

Vinculación HTTP+JSON estándar de A2A (las rutas están copiadas tal cual de la §11.3 de la especificación, y es la que anuncia la Agent Card):

POST   /a2a/v1/message:send              # SendMessage → Task
GET    /a2a/v1/tasks/{id}                # GetTask
GET    /a2a/v1/tasks                     # ListTasks (paginación 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         # sin implementar → UnsupportedOperation

Streaming y push. message:stream pasa por las mismas validaciones y puertas que message:send y responde text/event-stream, una línea data: por cada StreamResponse: primero {task}, después un {statusUpdate} cuando la tarea termina (o llega a AUTH_REQUIRED, a la espera de una persona), y se cierra. En esta ruta la respuesta es un único turno del LLM sin salida parcial, así que la granularidad es de eventos de tarea. :subscribe acepta GET y POST (el proto dice GET, las tablas de la spec POST) y responde UnsupportedOperation a una tarea terminada. Push: un peer registra un webhook en una tarea (o envía configuration.taskPushNotificationConfig en message:send) y cada estado que alcanza la tarea se envía una vez por POST como {task}, con Authorization: {scheme} {credentials} y el token en X-A2A-Notification-Token. El webhook debe ser https y resolver a una dirección pública (la misma regla SSRF que el A2A saliente, comprobada de nuevo al entregar, sin seguir redirecciones); las credenciales se guardan selladas y solo se devuelve scheme. Como máximo 5 por tarea, visibles y borrables solo por el peer que los registró.

El dialecto propio de Confer (para hablar entre instancias; se descubre por /.well-known/agents.json):

POST   /a2a/v1/messages                  # recibe mensajes de Agentes externos
GET    /a2a/v1/stream/{message_id}       # recoge la respuesta en flujo (SSE)
GET    /a2a/v1/agent-facts/{agent_did}   # AgentFacts público

Todos los endpoints A2A exigen verificación de la firma HTTP del mensaje, salvo agent-facts: igual que /.well-known/agents.json, es un documento público de descubrimiento con la misma visibilidad, así que un Agente no público o dado de baja devuelve 404 siempre. Sus capabilities solo incluyen lo que el dueño guardó con la forma NANDA ({type, scope, languages}).

.well-known endpoints

GET    /.well-known/did.json                # documento DID principal
GET    /.well-known/agents.json             # lista de todos los Agentes públicos de esta instancia
GET    /.well-known/agent-card.json         # Agent Card estándar de A2A (solo si la instancia tiene un único Agente público)
GET    /.well-known/openid-configuration    # a futuro: compatibilidad OIDC (v2)

Agent Card estándar de A2A (capa de descubrimiento interoperable)

GET    /agents/{username}/agent-card.json   # la Card estándar A2A de ese Agente
GET    /.well-known/agent-card.json         # lo mismo, solo si esta instancia tiene un único Agente público

Sigue el AgentCard de Agent2Agent v1.0 de la Linux Foundation (los campos salen de specification/a2a.proto de a2aproject/A2A @ v1.0.1 y usan el mapeo JSON de proto3, de ahí el camelCase). Su fin es que el ecosistema A2A descubra a los Agentes de esta instancia: los nombres coincidían pero los protocolos no se entendían, porque el documento de descubrimiento del otro lado está en /.well-known/agent-card.json y esta instancia solo tenía /.well-known/agents.json.

Algunas decisiones deliberadas:

Capa de mensajes (semántica de Task)

POST /a2a/v1/message:send recibe el SendMessageRequest de la especificación y devuelve un Task. Una tarea es una pregunta entrante: su id es el id de ese mensaje, su contextId es la conversación donde se archiva, y su estado se deduce de lo que ocurre después — no hay una tabla tasks aparte que sea la sombra del mismo hecho.

El modelo asíncrono de Confer, con su puerta de consentimiento, encaja exactamente en la máquina de estados de la especificación:

Situación Estado
El Agente está respondiendo TASK_STATE_WORKING
Ha terminado TASK_STATE_COMPLETED
Este turno no puede ni arrancar (sin modelo configurado, o el proveedor falló) TASK_STATE_FAILED
En suspenso por la política ask_user, a la espera del dueño TASK_STATE_AUTH_REQUIRED (estado de interrupción, no terminal)
El dueño lo rechazó TASK_STATE_REJECTED

Hay dos casos en los que no hay tarea que devolver, porque no se llegó a crear ninguna fila: el peer desconocido (que queda como solicitud de conexión pendiente) y el rechazo directo por política. Ambos responden 403 PERMISSION_DENIED y se distinguen por ErrorInfo.metadata.confer_status — inventar un id de tarea que daría 404 en la siguiente llamada sería peor.

El resto del comportamiento se alinea punto por punto con la especificación: el cuerpo de error tiene la forma de google.rpc.Status y siempre lleva ErrorInfo.reason (varios errores A2A comparten el mismo código HTTP, y reason es el único campo que los distingue); a un cliente que no declare la extensión obligatoria se le responde con ExtensionSupportRequiredError, según la §3.3.4, y no con un 401 que no explica nada; historyLength=0 significa omitir el campo entero, no mandar un array vacío; y nextPageToken está siempre presente, con cadena vacía cuando no hay página siguiente.

Dos desviaciones deliberadas, ambas anotadas en el código: la espera del message:send bloqueante tiene tope (55 s, tras los cuales se devuelve la tarea todavía en WORKING para que el cliente sondee) — la §3.2.2 no ofrece salida por tiempo, y una llamada a un LLM no tiene cota superior; y la idempotencia por messageId (un MAY de la §3.3.1) no está hecha, porque una clave única segura entre inquilinos necesita el ámbito del dueño y el formato de cable del primer mensaje no lo trae.

Webhooks (opcional, v1.5+)

Permiten que sistemas externos se suscriban a eventos:

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

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

Política de límite de tasa

Ruta Límite
/api/v1/auth/login 10/minuto por IP
/api/v1/auth/register 3/hora por IP
/api/v1/conversations/*/messages POST 60/minuto por usuario
/a2a/v1/* 60/minuto por IP y ruta, y 300/minuto por IP en todo /a2a/v1
Preguntas A2A (ambos bindings) Por DID de peer y dueño 20/minuto y 300/día; por dominio del peer 60/minuto. Excedido → 429 / RESOURCE_EXHAUSTED con Retry-After
/api/v1/consult/{peer} POST 30/minuto por usuario
WSS como mucho 10 conexiones simultáneas por usuario

Respuesta al superar el límite:

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 (salida A2A iniciada por el usuario)

Permite que el usuario (o el servidor MCP que actúa en su nombre) pregunte por iniciativa propia a un Agente ajeno que ya sea contacto suyo y recoja después la respuesta asíncrona. La firma y la entrega ocurren enteras dentro de la pasarela; la clave privada no sale de ella.

Diferencia con la «API de conversaciones»: /api/v1/conversations + /api/v1/stream es conversar con el asistente LLM local de uno mismo; /api/v1/consult es lo que se manda por A2A al Agente de otra persona.

POST /api/v1/consult/:peerId

Inicia o continúa una conversación de type='consult' (cada peer reutiliza la misma), y firma y entrega un message.type='question'.

// cuerpo de la petición (consultRequestSchema)
{ "question": "¿Cómo se rotan las claves?", "code_context": "…código opcional…", "language": "es" }
Respuesta Significado
201 { conversation_id, message_id, status: "sent" } firmada y entregada
502 { ..., status: "failed", error } falló la entrega (peer sin conexión, sin endpoint, o problema de firma)
403 not_a_contact el peer no es contacto del usuario actual

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

Espera por sondeo largo la respuesta asíncrona del peer (que llega por la entrada /a2a/v1/messages con su thread_id, y la pasarela la vuelve a colgar del hilo correspondiente). El tope de wait es 55 s.

GET /api/v1/consult/:conversationId

Devuelve el historial completo de ese hilo de consulta (200 mensajes como máximo).

Contrato: la entrada A2A solo dispara la respuesta automática del Agente local para message.type==='question'; answer y notification se guardan y se difunden, y nada más, para que la respuesta a una consulta no desencadene un intercambio infinito.

← Volver a Confer A2A · DID:web · RFC 9421 · NANDA