# agents-engine Motor de agentes de nicetry. Le da a cada tenant un agente (sobre eve) que actúa con los roles, skills y conexiones que el tenant define en ops, con una sesión por identidad (persona o rutina) y rol. Lo que entra es una identidad y un mensaje; lo que sale es un turno del agente que corre en segundo plano. API REST, también expone MCP. Un despliegue sirve a todos los tenants; la configuración de cada uno vive en Postgres. Todas las rutas de tenant van bajo `/{tenant}/…`. Cumple el **contrato de engines v1** de nicetry (errores, request id, actor, idempotencia, paginación y rate limit; ver abajo). Las rutas `/eve/v1/*` son del framework (protocolo nativo de eve) y no siguen este formato. ## Autenticación - `X-Engine-Key: `: en toda ruta `/{tenant}/…`. Es la clave del tenant para este engine; el slug del tenant es el mismo que en el resto de los engines de nicetry. - `x-admin-api-key: `: solo para `/admin/…`. - `/{tenant}/inbound/{source}` no usa clave: se autentica con la firma HMAC del origen (ver abajo). Públicas: `GET /` (puntos de entrada, JSON), `GET /health`, `/docs`, `/openapi.json`, `/llms.txt`. Sin enumeración: sin una key válida la respuesta es 401, exista o no el tenant. Con la key válida de otro tenant: 403 (también sobre una sesión ajena). Recién con una key válida, un tenant inexistente da 404. `GET /health` responde `{ status: "ok", engine, version, db: "ok" }` con un ping a la base; si la base no contesta, 503 con `db: "error"`. ## Identidad: headers A quién pertenece una sesión se dice por headers, nunca en el cuerpo: | Header | Formato | Qué identifica | |---|---|---| | `x-agent-user` | slug de usuario de ops | una persona | | `x-agent-routine` | slug de rutina de ops | un trabajo automatizado | | `x-agent-role` | `/` | el rol de ops con que actúa la sesión | | `x-agent-attributes` | objeto JSON de strings | datos extra para la sesión (solo entre servidores) | | `X-Actor` | `:`: `user:`, `agent:`, `loops:`, `panel:`, `admin:` | quién pidió la escritura (trazabilidad) | - Hace falta `x-agent-user` o `x-agent-routine`. Sin ninguno: 400. - Una sesión por **(persona o rutina, rol)**: el mismo usuario con dos roles tiene dos sesiones aisladas. Una rutina sin `x-agent-role` actúa con el primer rol de la rutina en ops, en todas las rutas. Una persona sin `x-agent-role`, o una rutina sin roles en ops, queda sin rol. - Los slugs son letras, números y guiones, con mayúsculas o minúsculas como estén en ops (`Ops/personal-assistant`); otra cosa se rechaza. - `x-agent-attributes` y `X-Actor` nunca pisan los atributos de identidad. `x-agent-attributes` mal formado se ignora. `X-Actor` sigue el formato `^[a-z][a-z0-9-]*:\S{1,200}$`. Fase de adopción: en una escritura, uno mal formado se registra tal cual con un warning; si falta, el `actor` del cuerpo o `unknown:`. Con `ACTOR_STRICT=1`, ausente o mal formado da 400. Nunca se exige en lecturas. Queda como atributo `actor` del turno y en el motivo de un reset. ## Sesiones - `GET /{tenant}/session` — ¿esta identidad ya tiene sesión? No crea nada. 200 `{ sessionId }`, o 204 si todavía no hay. - `POST /{tenant}/session` — busca la sesión o la crea. Sin cuerpo. 200 `{ sessionId }` si existía; 201 `{ sessionId }` si se creó. Una sesión recién creada ya tiene el rol resuelto desde su primer turno. - `POST /{tenant}/session/message` — manda un mensaje a la sesión de esta identidad, creándola si hace falta. Cuerpo: `{ message, turnPolicy?, clientContext? }`. `turnPolicy`: `"queue"` (por defecto: espera a que termine el turno en curso) o `"steer"` (lo interrumpe). `clientContext` es contexto que el modelo ve y el usuario no. 202 `{ sessionId }`: entregado, el turno corre en segundo plano. Acepta `Idempotency-Key`: con la misma key y el mismo cuerpo (para la misma identidad) dentro de 24 h devuelve la respuesta original sin volver a mandar el mensaje; con otro cuerpo, 409 `idempotency_conflict`. 400 sin identidad o sin `message`; 402 `budget_exhausted` con `Retry-After` si el tenant agotó su presupuesto de IA del mes; 503 `unavailable` si es una rutina sin rol explícito y ops no responde (reintentar); 502 si no se pudo entregar. - `POST /{tenant}/session/reset` — corta el turno en curso y desliga la sesión de esta identidad; el próximo contacto crea una nueva. Lo que pasó antes no se borra. Cuerpo opcional `{ reason }`. 200 `{ ok: true, address, actor?, status, previousSessionId? }`. - `POST /{tenant}/session/reset-all` — lo mismo para todas las identidades con sesión en este tenant; no usa headers de identidad. Cuerpo opcional `{ dryRun: true }` lista sin reiniciar. 200 `{ ok, reset, failed }` o `{ ok, dryRun, wouldReset }`. ## Roles - `GET /{tenant}/roles` — los roles del tenant, leídos de ops, para que el panel no necesite la clave de ops. 200 `{ roles: [{ title, team, slug, path, person? }] }` (vacío si ops no está configurado). ## Conexiones y fuentes de eventos Cómo llega este engine a ops, whatsapp y hub en nombre del tenant, y de qué eventos se entera. Las configura el admin al dar de alta o migrar un tenant. - `GET /{tenant}/connections` — 200 `{ connections: [{ system, baseUrl, meta, updatedAt }] }`, nunca el secreto. Paginado (ver Listados). - `PUT /{tenant}/connections/{ops|whatsapp|hub}` `{ baseUrl, secret }` — `secret` es la X-Engine-Key del tenant en ese engine. ops: `baseUrl` con el tenant (`https://ops…/{tenant}`); whatsapp y hub: la raíz del engine. Prueba un GET real antes de guardar (ops `/roles`, whatsapp `/numbers`, hub `/kinds`): 422 si falla, si `baseUrl` no es https de un host admitido o si resuelve a una dirección no pública (loopback, red privada, metadata; la IP queda fijada para la conexión). El 422 nunca trae el cuerpo de la respuesta del otro lado. 200 con la conexión guardada. Sin ops el agente no tiene roles, skills ni rutinas (`/roles` responde vacío). - `DELETE /{tenant}/connections/{system}` — 204, o 404. - `POST /{tenant}/sources/{whatsapp|hub}/subscribe` `{ eventTypes?, routine? }` — crea en el origen una suscripción a `/{tenant}/inbound/{source}` y guarda su secreto. Idempotente: si ya existe la actualiza, nunca deja dos. `routine` es la rutina de ops que despierta cada evento (omitida conserva la anterior; null la quita). 201 creada / 200 actualizada `{ source, subscriptionId, url, eventTypes, routine, created }`; 409 sin conexión a esa fuente; 502 si el origen la rechaza. Eventos que despiertan al agente: whatsapp `message.received`; hub `card.created`, `card.moved`. `metadata.updated` y `entry.created` los produce el propio agente (o loops): pedirlos lo haría despertarse a sí mismo. - `GET /{tenant}/sources` — 200 `{ sources: [{ source, subscriptionId, routine, eventTypes, createdAt }] }`. Paginado (ver Listados). - `DELETE /{tenant}/sources/{source}` — borra la suscripción en el origen y acá. 204, o 404. - `POST /{tenant}/inbound/{source}` — la llama el origen, no un cliente. Firma `X-Signature-256: sha256=` con el secreto de la suscripción (401 si no coincide; un tenant o una fuente sin suscripción responden igual). Con `X-Event-Id`, un id ya visto responde 200 `{ duplicate: true }` sin hacer nada; un `X-Event-Timestamp` de más de 24 h, 400 `stale_event`. Cuerpo JSON con `tenantSlug` (igual a `{tenant}`, si no 403) y opcionalmente `type`; los demás campos llegan a la sesión como atributos con el prefijo de la fuente (`numberId` de `whatsapp` → `whatsappNumberId`). Despierta la sesión de la rutina asignada a la fuente con un mensaje marcado `[notification:]`, sin contenido del evento: el agente consulta el estado real. Responde 202 `{ accepted, woke }` enseguida y despierta la sesión en segundo plano; sin rutina asignada, 202 con `woke: null`. 402 sin presupuesto, 503 si ops no responde: el origen reintenta. ## Bus de eventos Mientras dura la migración al bus, los eventos de whatsapp y hub llegan por los dos caminos: el directo (`/{tenant}/inbound/{source}`) y el bus. - `POST /{tenant}/inbound` — la llama el bus. Firma `X-Bus-Signature: t=,v1=` con el secreto de la suscripción de agents; un `t` a más de 5 min, una firma mala o un tenant sin configuración del bus: 401. El cuerpo es el envelope (un evento). Su `id` es el mismo que viaja por el camino directo: llegue primero por donde llegue, se procesa una vez (la segunda, 200 `{ duplicate: true }`). Un type que la fuente no declara: 202 `ignored`. A la sesión llegan solo ids (`numberId`, `contactWaId`, `messageId`, `entityId`…), nunca el texto del cliente. - Los avisos a una misma rutina (por los dos caminos) se juntan: el primero despierta en el acto y abre una ventana de 10 s; lo que llega dentro de la ventana (202 `{ coalesced: true }`) sale en un solo despertar más al cerrarla. Como mucho dos turnos por ventana, y el último aviso de una ráfaga siempre despierta. - `PUT /admin/tenants/{slug}/bus` `{ apiKey, subscriptionSecret? }` — la key de agents en el bus (se prueba contra el bus: 422 si no la acepta) y el secreto de firma. `GET` del mismo path: `{ configured, keyFingerprint, secretConfigured, types }`; `types` son los types que agents declara por fuente, para armar la suscripción. `DELETE`: 204. - `GET /admin/tenants/{slug}/bus/pairing?olderThanMinutes=30` — los eventos que llegaron por un solo camino, con type, camino y hora, y los totales, contando solo los types de la suscripción del bus. `olderThanMinutes` hasta 44640 (31 días, la retención de los ids). ## Mantenimiento - `POST /{tenant}/cache/invalidate` — fuerza a releer de ops antes de que venza el cache de 5 minutos. Cuerpo: `{}` (todo), `{ team, slug }` (un rol), `{ user }`, `{ routine }`, combinables. 200 `{ ok: true }`. - `POST /{tenant}/sandbox/delete` — borra sandboxes de Vercel del tenant. Cuerpo: `{}` (todas), `{ personSlug }`, `{ name }`, `{ onlyUnlinked: true }`. 200 `{ ok, deleted, failed }`; 502 si la API de Vercel no responde. - `GET /{tenant}/browser/{sessionId}` — página HTML con la vista en vivo del navegador de una sesión de persona. 404 si la sesión no está ligada a una persona o no tiene sandbox. ## Admin Con `x-admin-api-key`. - `GET /admin/tenants` — 200 `{ tenants: [slug] }`. Paginado. - `POST /admin/tenants` `{ slug }` — upsert. 201 `{ slug, apiKey, created: true }` la primera vez (la clave se muestra solo ahí); 200 `{ slug, apiKey: null, created: false }` si ya existía. - `POST /admin/tenants/{slug}/rotate-key` — 200 `{ slug, apiKey }`; la vieja deja de servir en el acto. 404 si no existe. - `DELETE /admin/tenants/{slug}` — si el tenant tiene datos reales (sesiones o gasto registrado) exige `{ "confirm": "" }`; sin eso, 409 `confirm_required`. Reinicia sus sesiones, borra sus sandboxes, las suscripciones que el engine creó en whatsapp y hub y después todo lo del tenant. 200 `{ slug, deleted: true, reset, warnings }`; 404 si no existe. La memoria del agente queda (eve la guarda bajo un digest). - `GET /admin/tenants/{slug}/budget` — 200 `{ slug, budget, usage }`: presupuesto mensual de IA (`null` = sin tope) y gasto del mes en curso (UTC), sumado por paso de modelo. - `PUT /admin/tenants/{slug}/budget` `{ monthlyUsd }` — fija el tope; `null` lo quita. Agotado, los mensajes y avisos nuevos responden 402 hasta el mes siguiente (también `POST /eve/v1/session/{id}`); cortar o reiniciar sesiones sigue andando. - Sin `ADMIN_API_KEY` configurada: 401 en todas (cerrado). ## Convenciones de mensajes Lo que no escribió una persona va marcado al principio del mensaje: `[notification:]` (evento de whatsapp o hub, o aviso de loops), `[bootstrap:
]` (arranque interno de una sesión). ## Eventos Todavía no: `POST /{tenant}/subscriptions` y los eventos firmados (`session.created`, `session.reset`, `turn.completed`). ## Listados `GET /admin/tenants`, `GET /{tenant}/connections` y `GET /{tenant}/sources` aceptan `?limit` (1 a 500, 100 por defecto) y `?cursor`. El cuerpo es el de siempre; la página siguiente va en el header `X-Next-Cursor`, ausente en la última. Un `limit` fuera de rango o un cursor inválido: 400. ## Límites Rate limit por credencial: 600 pedidos por minuto (por instancia); pasado eso, 429 `rate_limited` con `Retry-After`. El freno de gasto por tenant es el presupuesto de IA. Cada turno corre en segundo plano y responde 202; no hay forma síncrona de esperar la respuesta por esta API. El protocolo nativo de eve (`/eve/v1/session/{id}`, stream de eventos) sigue disponible con la misma clave para quien lo necesite. ## Request id Cada respuesta trae `X-Request-Id`: el que mandó el cliente (hasta 128 caracteres `[A-Za-z0-9_.:-]`) o uno generado. Va en el log de la request y en toda llamada que el engine hace por ella. ## Errores Una sola forma: `{ "error": "para una persona", "code": "estable", "details"?: [ … ], "requestId": "…" }`. Un programa compara `code`: 400 `validation_error` o `invalid_json` (cuerpo mal formado, nunca 500) · 401 `unauthorized` · 403 `forbidden` · 404 `not_found` (también rutas inexistentes, siempre JSON) · 409 `conflict`, `confirm_required`, `idempotency_conflict` · 402 `budget_exhausted` (presupuesto de IA del mes agotado: `details: [{ limitUsd, spentUsd }]`, `retryAfter` y `Retry-After` hasta el mes siguiente, UTC) · 422 `unprocessable` (conexión rechazada) · 429 `rate_limited` · 502 `upstream_error` (otro engine o Vercel) · 503 `unavailable` (la base u ops no responden) · 500 `internal` (el detalle va solo al log, con el requestId). ## MCP `POST /{tenant}/mcp` (mismo X-Engine-Key, Streamable HTTP sin estado): herramientas `help` (este texto) y `api` (cualquier llamada REST de este tenant: `method`, `path` relativo al tenant como "/session" o "/roles", `query`, `body`, `headers` limitados a x-agent-user, x-agent-role, x-agent-routine y x-actor). `api` reentra con la misma key y el mismo `X-Actor` del caller, y no alcanza `/mcp`, `/admin` ni `/eve` (el chequeo es sobre el path ya normalizado). Spec completo en `/openapi.json` · Swagger UI en `/docs`.