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
- Base URL:
https://{instance}/api - Codificación: JSON, UTF-8
- Formato de fecha y hora: ISO 8601, UTC (
2024-11-15T14:30:00Z) - Identificadores: ULID (
01HXKQ7Z2N3M4P5R6T7Y8Z9A0B) - Formato de error:
{
"error": {
"code": "invalid_request",
"message": "Human-readable message",
"details": { /* optional */ }
}
}
Autenticación
- Cliente de usuario:
Authorization: Bearer <jwt_access_token> - TTL del access token: 15 minutos; TTL del refresh token: 90 días
- Los dos tokens se distinguen por la reivindicación
typ(access/refresh) y no son intercambiables: la cabeceraAuthorizationsolo aceptaaccess, yPOST /auth/refreshsolo aceptarefresh. Antes solo se diferenciaban enexp, de modo que el refresh token era un pase de 90 días válido en cualquier endpoint autenticado y los 15 minutos del access token no servían de nada - El refresh rota en cada uso y se coteja contra
sessions.refresh_token_hash; si no coincide se considera una reutilización y toda la sesión se invalida.sessions.expires_ates el límite absoluto de la sesión: la rotación no lo prolonga - Los tokens se guardan en el almacenamiento local del cliente, no en una cookie HTTP-only (el cliente es una aplicación de escritorio Tauri, donde no existe el equivalente de las cookies de mismo origen)
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):
- Todas las consultas se limitan a
user.sub(aislamiento entre usuarios). - Antes de un PUT se comprueba que el peer sea contacto de ese usuario (
peer_contacts); si no lo es, se devuelve403 not_a_contact. - El PUT hace upsert: la primera escritura deja
version=1, y cada siguiente incrementaversiony actualizaupdated_at.factsydecisionsson independientes: escribir una sección no vacía la otra. GET facts/decisionsdevuelve200con una cadena vacía yversion:0cuando ese par (proyecto, peer) todavía no tiene memoria (no un 404: «este peer aún no ha dejado nada» es un estado normal en lectura).project_idse valida contra^[a-zA-Z0-9._\-/]+$(de 1 a 255 caracteres); si no cumple se devuelve400 invalid_project_id.GET peersdevuelve un array vacío en un proyecto sin nada. La relación (proyecto, peer) se crea implícitamente al hacer PUT de facts/decisions (en esta fase no hay registro explícito conPOST peers).
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.conffijaclient_max_body_size 10men/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.
- Los totales de tokens excluyen los turnos
unreported; si ningún turno de un grupo informó uso, los campos sonnull: no informado no es cero. - Los
cache_*forman parte deinput_tokens, no se suman a ellos; sonnullcuando el proveedor nunca habló de caché. - Las filas con
audience: peerson turnos que responden a un contacto conectado, y los paga igualmente el propietario. - Solo se registran turnos del agente (el chat web y el A2A entrante), una fila por turno, fallidos incluidos. La extracción de memoria y las llamadas de embedding no.
- Sin importes: los precios por modelo no se guardan localmente; el coste lo indica la factura del proveedor.
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:
- Una Card por Agente, con
supportedInterfaces[].tenant= nombre de usuario. El well-known de la especificación da por supuesto un Agente por dominio, y esta instancia es multiinquilino;tenantes precisamente el selector de enrutado que la especificación define para «varios Agentes detrás de un mismo endpoint A2A»./.well-known/agent-card.jsonsolo responde cuando hay exactamente un Agente público (el caso de quien se autoaloja en solitario); si no, devuelve 404 y en el mensaje de error apunta aagents.json— elegir una cuenta cualquiera y llamarla «el Agente de este dominio» sería sencillamente falso. streaming: true,pushNotifications: true.message:stream,:subscribey las rutas de configuración de push responden con las formas de la spec; el streaming es a nivel de eventos de tarea (la Task y después un estado final).- No se declara
securitySchemes. Lo que la especificación ofrece ahí es clave de API, autenticación HTTP, OAuth2, OIDC o mTLS, y este endpoint no acepta ninguno: lo que quiere es una petición firmada. Rellenar uno cualquiera equivaldría a decirle al cliente que puede autenticarse de una forma que se va a rechazar sin falta. El requisito real se declara como extensión obligatoria (capabilities.extensions, con la dirección de la RFC 9421 enuriyrequired: true), que es justo el mecanismo que la especificación prevé para esto. -
La Card es un documento de descubrimiento y su visibilidad es idéntica a la de
/.well-known/agents.json: un Agente no público o dado de baja devuelve 404 siempre; de lo contrario esta ruta se convertiría en una manera de enumerar cuentas que su dueño no quiso hacer públicas. - Se anuncia una sola vinculación. El dialecto propio de Confer vive en esta misma URL, pero no se escribe en la Card: la §5.1 exige que todas las vinculaciones que un Agente declare sean funcionalmente equivalentes, y el dialecto no tiene ciclo de vida de tareas. Se descubre por
/.well-known/agents.json, y así la Card no promete nada que no pueda cumplir.
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/streames conversar con el asistente LLM local de uno mismo;/api/v1/consultes 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.
200 { status: "answered", message }— ha llegado la respuesta200 { status: "pending" }— se agotó el tiempo sin respuesta; se puede volver a sondear más tarde
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';answerynotificationse guardan y se difunden, y nada más, para que la respuesta a una consulta no desencadene un intercambio infinito.