# loops-engine

Procesos acotados ("loops") que mantienen datos consistentes entre los
engines de nicetry. Un loop se dispara por un evento de otro engine
(whatsapp, hub), por horario o a mano; ejecuta pasos en orden (llamadas a
engines, una llamada de IA con salida estructurada, condiciones, avisos a
los agentes); y deja un run con cada paso, reintentos y costo.

El nombre viene de *loop engineering*: cada loop es un ciclo chico,
observable y repetible. **Nunca conversa con una persona.** Cuando hace
falta juicio o conversación, despierta una rutina de ops (el agente) con
contexto; eso es lo único que cruza el límite entre loops y ops.

Cumple el **contrato v1** de engines de nicetry.

## Autenticación

- `X-Engine-Key: <api_key>` en todas las rutas bajo `/{tenant}` (el mismo
  slug que el tenant tiene en los otros engines; cada engine guarda su
  propio hash). Sin una key válida: 401, exista o no el tenant; con la key
  de otro tenant: 403; con una key válida y un tenant que no existe: 404.
- `x-admin-api-key` sólo en `/admin/tenants` (alta, rotación, baja,
  presupuesto de IA y bus). `PUT /admin/tenants/{slug}/bus {apiKey,
  subscriptionSecret}` guarda la key de loops en el bus y el secreto de sus
  entregas (prueba la key contra el bus; si falla, 422); `GET` dice si
  están (`keyFingerprint`, sin secretos) y los `types` que loops pide para
  su suscripción; `DELETE` los borra. `GET …/bus/pairing?olderThanMinutes=30`:
  los eventos que llegaron por un solo camino (directo o bus) y los totales.
- `POST /{tenant}/inbound/{source}` no lleva key: se autentica con la firma
  HMAC del engine origen (`X-Signature-256`), que este engine guardó al
  suscribirse. Con `X-Event-Id`, una entrega repetida responde 202 sin
  encolar; un `X-Event-Timestamp` (segundos unix) a más de 24 h, 400. La
  ruta vieja `POST /inbound/{tenant}/{source}` sigue en paralelo, con
  `Deprecation`, para las suscripciones que ya apuntan ahí.
- `POST /{tenant}/inbound` recibe las entregas del bus de eventos: el
  envelope en el cuerpo y `X-Bus-Signature: t=<unix>,v1=<hmac>` (HMAC-SHA256
  de `t + "." + cuerpo` con el secreto de la suscripción `loops` en el
  bus). Firma mala o sin secreto cargado: 401; `t` a más de 5 min: 401. El
  `type` es `<fuente>.<type>` (`whatsapp.message.received`) y `data` trae
  lo mismo que el cuerpo directo. El mismo id por lo directo (X-Event-Id) y
  por el bus se procesa una sola vez. Un type que ningún loop del tenant
  escucha: 202 sin encolar.
- `X-Actor: <system>:<id>` en toda escritura (`panel:<email>`,
  `agent:<rutina>`…): queda en `updatedBy` del loop y en su historial.
  Hoy un actor que falta o no cumple el formato se acepta con un warning.
- `X-Request-Id`: si viene se respeta, si no se genera; vuelve en cada
  respuesta y viaja en las llamadas que el engine hace a otros.

Rate limit por minuto (429 + Retry-After): 600 por key, 120 por IP sin
credencial, 30 por IP en /admin/tenants. Los inbound (bus y directo) no
cuentan en el límite por IP: una entrega bien firmada no tiene límite, y
pasadas 120 firmas fallidas por IP en el minuto, una fallida es 429 en
vez de 401. Exentas de auth: /, /health, /docs, /openapi.json, /llms.txt.

## Convenciones

- **Errores**: `{error, code, details?, requestId}`. `code` es estable
  (`invalid_json`, `validation_error`, `unauthorized`, `forbidden`,
  `not_found`, `conflict`, `idempotency_conflict`, `retry_pending`,
  `unprocessable`, `rate_limited`, `upstream_error`, `unavailable`,
  `internal`). Una ruta que no existe es 404 en JSON.
- **Listados**: `?limit` (100 por defecto, hasta 500) y `?cursor`. El cuerpo
  es el array de siempre; si hay más, el header `X-Next-Cursor` trae el
  cursor de la página siguiente. Un limit o cursor inválido es 400.
- **Idempotencia**: todo POST bajo `/{tenant}` acepta `Idempotency-Key`.
  Misma key y mismo cuerpo dentro de 24 h: la respuesta original (con
  `Idempotent-Replayed: true`); otro cuerpo: 409 `idempotency_conflict`.
- **Fechas**: `?since` es ISO 8601; otra cosa es 400.

## Conexiones y fuentes

- `PUT /{tenant}/connections/{system}` con `{baseUrl, secret, meta?}`:
  cómo llega este engine a `whatsapp`, `hub`, `ops`, `data` y
  `agent` (el motor de agentes). Para ops, data y agent la baseUrl lleva
  el /{tenant} incluido; el secret es siempre la X-Engine-Key del tenant en
  ese engine. Solo https y nunca a loopback, redes privadas ni
  metadata de la nube (400; la IP resuelta se valida y queda fijada; no se
  siguen redirecciones). Se prueba con una llamada real antes de
  guardar (si falla, 422 con el status, nunca el cuerpo); el secreto queda
  cifrado (AES-256-GCM con AAD) y nunca se devuelve.
- `POST /{tenant}/sources/{source}/subscribe {eventTypes[]}`: crea en ese
  engine una suscripción apuntando a `/{tenant}/inbound/{source}` y guarda
  el secreto de firma (si el engine origen falla, 502). Los eventos entran,
  se verifican y se encolan (202).
- `GET/PATCH /{tenant}/settings`: ajustes que leen los loops del catálogo
  (`deal_pipeline_id`, `assignment_policy`, `whatsapp_routine`,
  `recontact_after` "48h", `recontact_every` "7d", `recontact_template`).

## Loops

`GET/POST /{tenant}/loops`, `GET/PATCH/DELETE /{tenant}/loops/{slug}`,
`POST …/enable|disable`, `POST …/run {input}`. `GET /{tenant}/catalog` y
`POST /{tenant}/loops/from-catalog {slug}` copian un loop predefinido al
tenant (queda editable). Una definición:

- `trigger`: `{kind: "event", source, type, entity?}` ·
  `{kind: "schedule", cron, tz}` · `{kind: "manual"}`.
- `when` (sólo eventos): `tags_any`, `tags_none`, `attributes_missing`,
  `attributes_present` sobre la entidad del evento. Un loop sobre
  `metadata.updated` de whatsapp **necesita `tags_any`**: el evento trae
  sólo ids y el contacto se resuelve buscando por esas tags.
- `idempotency_key`: plantilla; un run por clave y loop (duplicado → no se
  crea). `concurrency_key`: plantilla (ve `event`, `input`, `tenant`,
  `loop`); dos runs del loop con la misma clave no corren a la vez, el
  segundo espera en cola. Vacía o ausente: sin espera.
- Toda escritura en otro engine (lo que no es GET) va con
  `X-Actor: loops:<slug del loop>`.
- `steps`: en orden. Tipos:
  - `http {connection, method, path, query?, body?, id?, allow_404?}`
  - `ai {id, model: fast|standard, prompt, output_schema}` (JSON Schema;
    salida estructurada; tokens y costo quedan en el run). Presupuesto de IA
    por tenant y mes UTC (default 5 USD; el admin lo cambia con
    `PUT /admin/tenants/{slug}/ai-budget {monthlyUsd|null}`): agotado, el
    paso falla con `ai_budget_exhausted` y el run no se reintenta
  - `condition {if, then[], else?[]}` — `if` admite `{{a}}`, `!{{a}}`,
    `{{a}} == x`, `!=`, `>`, `<`, `{{a}} includes x`,
    `{{a}} older_than 48h`, `newer_than 10m`
  - `for_each {items, as, steps[], max?}`
  - `set {id, value}`
  - `metadata {entity: contact|conversation, number_id, wa_id, tags_add?,
    tags_remove?, set?, remove?}` — PATCH a whatsapp con
    `updated_by: loops:<slug>`
  - `policy {id, policy, context}` — evalúa una política de ops
  - `wake_routine {routine, role?, message, context?}` — despierta la
    rutina en el deployment del agente (mensaje con prefijo
    `[notification:loops]`)
  - `send_template {number_id, to, name, language, components?,
    only_if_window_closed, category_max}` — nunca manda MARKETING si
    `category_max` es UTILITY
  - `schedule_at {loop, at, input, key?}` — encola un run futuro
  - `enqueue {loop, input, idempotency_key?}` — encola un run ahora
  - `stop {reason?}` — termina el run como exitoso

Plantillas `{{path}}` sobre `event`, `input`, `settings`, `tenant`, `now`,
`loop` y la salida de cada paso por su `id` (en `for_each`, también el
ítem como `{{<as>}}` y `{{index}}`). Sin lógica: sólo paths.

Contexto `event` de whatsapp: `{type, number_id}` en `message.received`;
en `metadata.updated` (con `tags_any`): `{number_id, contact_wa_id,
entity_id, tags, attributes}`. De hub: `{type, entity_type, entity_id,
entity}` (la entidad ya leída).

## Catálogo

1. `etiquetar-conversacion` — tags `nuevo` / `activo` / `sin-respuesta` en
   los contactos pendientes con cada mensaje entrante.
2. `resumir-conversacion` — `ai.summary` en la conversación (manual o
   programado con `schedule_at`; input: number_id, wa_id, through).
3. `crear-deal` — con la tag `calificado`: contacto por identidad en
   Actividades, card `deal`, atributos `hub.card_id` / `hub.contact_id`,
   y encola `asignar-deal`.
4. `asignar-deal` — política de ops → owner en la card y `hub.owner` en la
   conversación → despierta la rutina para avisar al cliente.
5. `recontactar` — cron horario: sin respuesta hace `recontact_after` →
   rutina (ventana abierta) o plantilla utility (cerrada), como mucho una
   vez cada `recontact_every`; respeta la tag `no-contactar`.

## Campañas de WhatsApp

`GET/POST /{tenant}/campaigns`, `PUT/DELETE /{tenant}/campaigns/{slug}`: un
loop por campaña (`campana-<slug>`, trigger schedule) generado a partir de
parámetros — `number_id`, `template` + `language` (+ `components`),
`category` máxima (MARKETING | UTILITY), `audience` {tags (todas),
exclude_tags, mode}, `schedule` {cron, tz}, `per_run_limit` y
`repeat_every` (null = una vez por contacto). La tag `no-contactar` siempre
queda afuera (opt-out: la pone el agente cuando el cliente lo pide). Cada
envío deja la tag `campana-<slug>` y `marketing.<slug>.at` en el contacto;
la lista trae `stats` (corridas, envíos, fallos). Habilitar, deshabilitar,
correr ahora y borrar: los endpoints de `/loops/{loopSlug}`. Lo que
necesita conversar (responder según lo que dice el cliente) no es una
campaña: es una rutina de ops.

## Runs

`GET /{tenant}/runs?loop&status&since&limit&cursor`, `GET /{tenant}/runs/{id}`
(con `steps`: input, output, error y duración de cada paso),
`POST …/retry`, `POST …/cancel`, `GET /{tenant}/stats?since` (runs por
loop y estado, costo por día). Un run fallido se reintenta 3 veces con
backoff; después queda `failed` (con `ai_budget_exhausted`, sin
reintentos). Mientras hay un reintento automático
agendado, `POST …/retry` responde 409. Un run nunca corre dos veces a la
vez: quien lo ejecuta lo toma con un lease y late mientras corre. Los pasos son
idempotentes por construcción (PATCH aditivos, creación guardada por
`when` e idempotency_key, envíos con guardas).

## Recursos

Spec completo en `/openapi.json` / Swagger UI en `/docs`; `/health` con
`version` (el commit) y `db`. MCP en
`/{tenant}/mcp` con `help` y `api`.
