# Usar con agentes de IA (/docs/agents)
Esta documentación es **AI-first**: todo el contenido está disponible en formatos que un
agente puede consumir directamente, sin scraping. Elige el mecanismo que mejor le sirva a tu
herramienta.
## Servidor MCP [#servidor-mcp]
El portal expone un servidor [MCP](https://modelcontextprotocol.io) (transporte Streamable
HTTP) con herramientas para listar, buscar y leer páginas:
```bash title="Claude Code"
claude mcp add --transport http opensolvex-docs https://docs.opensolvex.co/api/mcp/mcp
```
```json title="Cursor / clientes con mcp.json"
{
"mcpServers": {
"opensolvex-docs": {
"url": "https://docs.opensolvex.co/api/mcp/mcp"
}
}
}
```
Herramientas disponibles: `list_docs` (árbol de páginas), `search_docs` (búsqueda por texto)
y `read_doc` (contenido completo de una página en Markdown). El servidor es de solo lectura
y no requiere autenticación.
## Markdown por página [#markdown-por-página]
Toda página existe como Markdown limpio. Agrega el sufijo `.md` a cualquier URL:
```bash
curl https://docs.opensolvex.co/docs/agents.md
```
También funciona la negociación de contenido — si tu cliente envía
`Accept: text/markdown`, la URL normal responde Markdown. Y en la UI, cada página tiene un
botón para copiar el Markdown o abrirla directamente en tu asistente.
## llms.txt [#llmstxt]
Siguiendo [llmstxt.org](https://llmstxt.org):
* [`/llms.txt`](/llms.txt) — índice de todas las páginas con título y descripción.
* [`/llms-full.txt`](/llms-full.txt) — el contenido completo de la documentación en un solo
archivo Markdown, listo para cargar como contexto.
# Bienvenido (/docs)
OpenSolvex es una plataforma de productos que se consumen por **API versionada**:
suscripciones y entitlements, canales de mensajería (WhatsApp, Telegram…), agentes de IA y
más. Esta documentación reúne todo lo que necesitas para integrarte: conceptos, guías paso a
paso y referencia de APIs.
## Cómo está organizada [#cómo-está-organizada]
## Pensada para agentes [#pensada-para-agentes]
Es normal (y recomendado) integrarte con ayuda de un agente de IA. Esta documentación está
diseñada para eso: cada página existe como Markdown limpio, hay un índice
[`llms.txt`](/llms.txt) y un volcado completo [`llms-full.txt`](/llms-full.txt), y puedes
conectar tu asistente por [MCP](/docs/agents) para que busque y lea la documentación por su
cuenta.
# Conectar un canal con un agente de Cortex (/docs/channels/conectar-cortex)
Un canal de Channels recibe los mensajes de tus usuarios; un agente de Cortex los responde con
IA. Conectarlos es un flujo de **dos pasos por la interfaz**, sin API keys ni código: generas
una **conexión** en Cortex (que te da una URL y un secreto) y la pegas en tu canal.
* Un **canal** ya creado en Channels (WhatsApp, Telegram o API).
* Un **agente** ya creado en Cortex.
## 1. Crea la conexión en Cortex [#1-crea-la-conexión-en-cortex]
En el portal de **Cortex**, ve a **Conexiones → Nueva conexión**. Ponle un nombre (p. ej.
«WhatsApp ventas») y elige el **agente por defecto** que atenderá los mensajes de este canal.
Al crearla, Cortex te muestra **una sola vez** dos valores:
| Valor | Qué es |
| ---------------------- | ------------------------------------------------------------ |
| **URL del handler** | El endpoint de ingesta de esta conexión, único por conexión. |
| **Secret del handler** | El secreto compartido con el que se firma cada mensaje. |
Cópialos ahora. El secreto se guarda cifrado y no vuelve a mostrarse. Si lo pierdes, usa
**Rotar secreto** en el detalle de la conexión para generar uno nuevo (el anterior deja de
funcionar al instante).
## 2. Pega la URL y el secreto en el canal [#2-pega-la-url-y-el-secreto-en-el-canal]
En el portal de **Channels**, abre el canal y busca la tarjeta **«Enrutamiento entrante →
handler»**:
1. Pega la **URL del handler** en el campo *URL del handler*.
2. Pega el **Secret del handler** en el campo *Secret del handler*.
3. Activa el switch **«Enviar mensajes entrantes al handler»** y guarda.
Desde ese momento, cada mensaje que entre al canal se firma y se envía a tu conexión de Cortex.
## 3. Pruébalo [#3-pruébalo]
Escríbele al canal (al número de WhatsApp, al bot de Telegram, o por la API del canal). Si todo
está bien:
1. Channels normaliza el mensaje y lo firma con el secreto de la conexión.
2. Cortex verifica la firma, resuelve el agente y crea una **sesión**.
3. El agente razona y, si responde, la respuesta vuelve al usuario por el mismo canal.
Por defecto, el **agente de la conexión**. Si defines **reglas de enrutamiento** en Cortex
(por inbox, tipo de canal, etc.), esas mandan y el agente por defecto queda como respaldo para
lo que ninguna regla cubra.
## Cómo funciona por dentro [#cómo-funciona-por-dentro]
Channels y Cortex son dos servicios independientes que se comunican solo por API — no comparten
base de datos. La conexión establece un canal seguro en cada sentido:
```
Usuario ─▶ Proveedor ─▶ Channels
│ firma HMAC (x-channels-signature)
▼
POST {URL de la conexión} ◀── entrante, autenticado por firma
│ Cortex verifica la firma con el secreto de la conexión
│ el tenant y el agente salen de la conexión (no del mensaje)
▼
Agente (razona, usa tools)
│ cuando produce respuesta
▼
POST /v1/messages ─▶ Channels ─▶ Proveedor ─▶ Usuario ◀── saliente
```
* **Entrante (Channels → Cortex)**: cada webhook va firmado con HMAC-SHA256 usando el secreto de
la conexión, con protección anti-replay. Cortex autentica **por la firma**, no por API key. El
tenant y el agente por defecto salen de la conexión: un mensaje no puede suplantar a otra
organización.
* **Saliente (Cortex → Channels)**: la respuesta **no es inmediata ni viaja en la respuesta del
webhook** (el agente puede tardar, encadenar herramientas o esperar una aprobación). Cuando el
agente termina, Cortex hace una llamada aparte a la API de envío de Channels
(`POST /v1/messages`), que entrega el mensaje por el canal original.
El envío de respuestas requiere que el despliegue de **Cortex** tenga configuradas
`CHANNELS_API_URL` y `CHANNELS_API_KEY` (una API key de Channels con scope `messages:send`). Es
un paso de configuración **una sola vez por entorno**, no por conexión. Sin ellas, el agente
razona pero la respuesta no se entrega.
## Solución de problemas [#solución-de-problemas]
| Síntoma | Causa probable |
| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| El mensaje no llega a Cortex | El switch del handler está apagado, o la URL no es la de la conexión. |
| Cortex responde **404** | La conexión fue eliminada o está deshabilitada, o el secreto pegado en Channels no coincide — rótalo y vuelve a pegarlo. (Todo fallo de autenticación responde el mismo 404; el motivo exacto queda en el log de Cortex.) |
| El agente procesa pero el usuario no recibe respuesta | Falta `CHANNELS_API_URL`/`CHANNELS_API_KEY` en el despliegue de Cortex. |
| No pasa nada y no hay error | Ninguna regla de enrutamiento coincide y la conexión no tiene agente por defecto. |
# Channels (/docs/channels)
Channels es el servicio de canales de OpenSolvex: recibe los mensajes que te escriben tus
usuarios por WhatsApp, Telegram u otros proveedores, los normaliza, y entrega tus respuestas
por el mismo canal. No razona ni decide qué responder — eso lo hace **Cortex**, el motor de
agentes de IA. La consigna es: *Cortex piensa, Channels habla*.
Para que un canal responda automáticamente con un agente de IA, conéctalo a Cortex.
# Cortex (/docs/cortex)
Cortex es el motor de agentes de IA de OpenSolvex. En él configuras **agentes** que
responden con un modelo de lenguaje, siguen instrucciones (**skills**), usan herramientas
externas (**tools** vía MCP u OpenAPI), acceden a credenciales de forma segura
(**secretos**), reciben tráfico según **reglas de enrutamiento** y reaccionan a eventos con
**hooks**.
Todo eso puedes administrarlo recurso por recurso desde el portal — o declararlo completo
como código con **stacks**, el módulo de infraestructura declarativa de Cortex.
# Primeros pasos (/docs/platform/getting-started)
Las guías completas por producto (suscripciones, canales, agentes) se están escribiendo.
Esta página cubre el flujo común a todos.
## 1. Crea tu organización [#1-crea-tu-organización]
Regístrate en la [consola de OpenSolvex](https://console.opensolvex.co) y crea tu
organización. Ese es tu tenant: todo lo que crees vive dentro de ella.
## 2. Genera una API key [#2-genera-una-api-key]
Desde el panel del producto que vas a consumir, genera una API key de **sandbox** con los
scopes que necesites. Guárdala en una variable de entorno:
```bash
export OPENSOLVEX_API_KEY="osx_sk_xxx" # tu key de sandbox
```
## 3. Haz tu primera llamada [#3-haz-tu-primera-llamada]
Toda API expone un health check público y endpoints versionados bajo `/v1`:
```bash
curl https://.opensolvex.co/v1/health
```
Con tu key ya puedes consumir los endpoints del producto. Sigue con la guía del producto
que te interese en el menú lateral — o [conecta tu agente de IA](/docs/agents) y deja que
te guíe con esta misma documentación.
# Conceptos de la plataforma (/docs/platform)
Todos los productos de OpenSolvex comparten el mismo modelo base. Entenderlo una vez te
sirve para integrarte con cualquiera de ellos.
## Tenants [#tenants]
Tu cuenta en OpenSolvex es una **organización**: todos los recursos que creas (canales,
suscripciones, agentes…) pertenecen a tu organización y están aislados de las demás. Toda
petición autenticada opera en el contexto de un tenant.
## Autenticación machine-to-machine [#autenticación-machine-to-machine]
Los servicios se consumen con **API keys** con *scopes* (permisos mínimos por operación).
Las keys se crean y rotan desde el panel de cada producto y se envían en el header
`Authorization`:
```bash
curl https://api.opensolvex.co/v1/... \
-H "Authorization: Bearer osx_sk_xxx"
```
Nunca publiques una API key ni la escribas en tu repositorio. Usa variables de entorno,
asigna a cada key los scopes mínimos que necesite y rótala si sospechas que se filtró.
## Modo sandbox [#modo-sandbox]
Cada API key puede ser de **sandbox**: opera sobre datos aislados de producción para que
pruebes tu integración de punta a punta sin riesgo. Los ejemplos de esta documentación
asumen sandbox.
## Convenciones de las APIs [#convenciones-de-las-apis]
* REST versionada bajo `/v1`; los cambios incompatibles solo llegan en versiones nuevas.
* Errores con cuerpo JSON estructurado (código, mensaje y detalles).
* Operaciones de escritura idempotentes vía idempotency keys donde aplica.
# Referencia de la API (/docs/cortex/stacks/api)
Todo lo que hace el portal de stacks está disponible por REST bajo `/v1/iac`. Esta página es
la referencia completa — pensada también para agentes de IA que crean y despliegan stacks
por ti (esta misma página existe como Markdown limpio agregando `.md` a la URL).
```bash
export CORTEX_API_URL="https://cortex.opensolvex.co" # http://localhost:3006 en desarrollo
```
## Autenticación [#autenticación]
Dos mecanismos, el mismo contrato:
* **API key (machine-to-machine, CI, agentes):** header `x-api-key: osx_sk_xxx`. Si la key
pertenece a un tenant, todas las operaciones ocurren en ese tenant y el campo `tenantId`
del body/query se ignora. Las keys de plataforma deben enviar `tenantId` explícito: en el
body de los `POST`/`DELETE` y como query param (`?tenantId=org_x`) en los `GET`; sin él,
la operación responde `400`.
* **Token de portal (humanos):** header `Authorization: Bearer ` emitido por la
consola de OpenSolvex; el tenant se toma de tu organización.
Toda operación queda auditada con su actor (`api-key:` o `user:`). Cuando una key
de plataforma opera en nombre de un tenant, el actor queda decorado como
`api-key: on-behalf-of:` y los eventos de dominio `iac.stack.*` llevan
`onBehalfOf: true` — así se puede auditar qué hizo cada key de plataforma y sobre qué
tenants.
## Scopes [#scopes]
| Scope | Permite |
| ------------ | ------------------------------------------------------------------------------ |
| `iac:read` | Listar y leer stacks, changesets y eventos |
| `iac:manage` | Validar templates, crear stacks, proponer changesets, detectar drift, exportar |
| `iac:deploy` | Ejecutar changesets y eliminar stacks |
Un scope insuficiente responde `403`. `iac:deploy` equivale a administrar el tenant
completo — ver [Seguridad](/docs/cortex/stacks/security).
## Endpoints [#endpoints]
| Método | Ruta | Scope |
| -------- | ---------------------------------------------- | ------------ |
| `POST` | `/v1/iac/templates/validate` | `iac:manage` |
| `GET` | `/v1/iac/stacks` | `iac:read` |
| `POST` | `/v1/iac/stacks` | `iac:manage` |
| `GET` | `/v1/iac/stacks/:id` | `iac:read` |
| `GET` | `/v1/iac/stacks/:id/events` | `iac:read` |
| `GET` | `/v1/iac/stacks/:id/change-sets` | `iac:read` |
| `POST` | `/v1/iac/stacks/:id/change-sets` | `iac:manage` |
| `POST` | `/v1/iac/stacks/:id/change-sets/:csId/execute` | `iac:deploy` |
| `POST` | `/v1/iac/stacks/:id/detect-drift` | `iac:manage` |
| `DELETE` | `/v1/iac/stacks/:id` | `iac:deploy` |
| `GET` | `/v1/iac/export` | `iac:manage` |
### Validar un template [#validar-un-template]
`POST /v1/iac/templates/validate` — dry-run puro, no muta nada.
```json title="Request"
{ "template": "" }
```
`200` si es válido (con los recursos detectados y el orden de despliegue); `422` si no:
```json title="Respuesta 422"
{
"message": "Template inválido",
"errors": [
{ "path": "resources.Modelo.properties.accountId", "message": "referencia a un nombre inexistente" }
]
}
```
### Crear un stack [#crear-un-stack]
`POST /v1/iac/stacks` — crea el stack en `REVIEW_IN_PROGRESS` con su changeset inicial.
**No materializa recursos**: eso ocurre al ejecutar el changeset.
```json title="Request"
{
"name": "mi-primer-stack",
"template": "",
"parameters": { "OpenaiApiKey": "sk-..." }
}
```
* `name`: kebab-case (`^[a-z0-9][a-z0-9-]*$`), único por tenant (`409` si ya existe).
* `parameters`: valores para los parámetros del template. Los `noEcho` nunca vuelven en
ninguna respuesta.
Responde `{ "stack": { ... }, "changeSet": { ... } }` con los ids de ambos.
### Leer stacks [#leer-stacks]
* `GET /v1/iac/stacks` — lista con nombre, estado y metadatos.
* `GET /v1/iac/stacks/:id` — detalle: `status`, `statusReason`, `templateSource` (el YAML
vigente), `parameters` (redactados), `outputs` y `resources[]` (nombre lógico → tipo, id
físico, estado, drift).
* `GET /v1/iac/stacks/:id/events` — traza de despliegue, más recientes primero: recurso,
estado, motivo, actor, timestamp.
### Proponer un changeset [#proponer-un-changeset]
`POST /v1/iac/stacks/:id/change-sets`
```json title="Request"
{
"template": "",
"parameters": { "OpenaiApiKey": "sk-..." }
}
```
Valida el template, calcula el diff contra lo aplicado y responde el changeset en `pending`.
El pendiente anterior (si lo había) queda `superseded`. `409` si el stack está en progreso.
```json title="Respuesta (extracto)"
{
"id": "chs_01H...",
"status": "pending",
"diff": {
"entries": [
{ "logicalId": "AgenteVentas", "type": "Cortex::Agent", "action": "modify", "changedFields": ["systemPrompt"] },
{ "logicalId": "CrmTools", "type": "Cortex::ToolSource", "action": "replace", "replacement": "delete-first", "reason": "kind es inmutable" }
],
"applyOrder": ["..."],
"removeOrder": ["..."]
}
}
```
`GET /v1/iac/stacks/:id/change-sets` lista el historial completo.
### Ejecutar un changeset [#ejecutar-un-changeset]
`POST /v1/iac/stacks/:id/change-sets/:csId/execute` — sin body. Responde `202` y despliega
en segundo plano. El changeset debe estar `pending`; `409` si el stack ya está en progreso.
Para saber el resultado, consulta el stack hasta que salga de `*_IN_PROGRESS`
(`CREATE_COMPLETE`/`UPDATE_COMPLETE` = éxito; `ROLLBACK_COMPLETE`/`UPDATE_ROLLBACK_COMPLETE`
\= falló y se revirtió; los detalles quedan en `/events`).
### Detectar drift [#detectar-drift]
`POST /v1/iac/stacks/:id/detect-drift` — compara lo aplicado con lo que existe y responde el
estado por recurso (`IN_SYNC`, `DRIFTED`, `DELETED`) con el diff por propiedad.
### Eliminar un stack [#eliminar-un-stack]
`DELETE /v1/iac/stacks/:id`
```json title="Request (opcional)"
{ "retain": ["SkillCalificar"] }
```
Responde `202` con `{ "status": "DELETE_IN_PROGRESS" }`. Elimina los recursos en orden
inverso; los nombres en `retain` sobreviven desasociados (`400` si un nombre no existe en el
stack). El historial del stack se conserva.
### Exportar un template [#exportar-un-template]
`GET /v1/iac/export` — genera un template YAML a partir de los recursos existentes del
tenant (`?resources=` para seleccionar). Útil para adoptar como código lo creado a mano; el
resultado usa la forma JSON de las intrínsecas, equivalente a los tags.
## Códigos de error [#códigos-de-error]
| Código | Cuándo |
| ------ | ----------------------------------------------------------------------------- |
| `400` | Body inválido (nombre mal formado, `retain` con nombres desconocidos) |
| `401` | Sin credenciales o credenciales inválidas |
| `403` | Scopes insuficientes |
| `404` | Stack o changeset inexistente (o de otro tenant) |
| `409` | Nombre de stack duplicado; stack en progreso (mutex); changeset no ejecutable |
| `422` | Template inválido (con `errors[]` detallado) |
## Receta para agentes [#receta-para-agentes]
El flujo mínimo que un agente debe seguir para dejar un stack funcionando:
```bash
# 1. Validar
curl -sX POST "$CORTEX_API_URL/v1/iac/templates/validate" \
-H "x-api-key: $CORTEX_API_KEY" -H "Content-Type: application/json" \
-d "$(jq -n --rawfile t template.yaml '{template: $t}')"
# 2. Crear stack (guarda stack.id y changeSet.id de la respuesta)
curl -sX POST "$CORTEX_API_URL/v1/iac/stacks" \
-H "x-api-key: $CORTEX_API_KEY" -H "Content-Type: application/json" \
-d "$(jq -n --rawfile t template.yaml \
'{name: "mi-stack", template: $t, parameters: {OpenaiApiKey: env.OPENAI_API_KEY}}')"
# 3. Ejecutar el changeset
curl -sX POST "$CORTEX_API_URL/v1/iac/stacks/$STACK_ID/change-sets/$CHANGESET_ID/execute" \
-H "x-api-key: $CORTEX_API_KEY"
# 4. Esperar hasta CREATE_COMPLETE (o ROLLBACK_COMPLETE → revisar /events)
until [ "$(curl -s "$CORTEX_API_URL/v1/iac/stacks/$STACK_ID" \
-H "x-api-key: $CORTEX_API_KEY" | jq -r .status)" != "CREATE_IN_PROGRESS" ]; do sleep 3; done
```
Reglas que el agente debe respetar:
* Los valores sensibles van **solo** en `parameters` (contra parámetros `noEcho`), nunca
dentro del YAML.
* Nunca asumas éxito tras el `202`: consulta el estado final y, si hubo rollback, lee
`/events` para diagnosticar antes de re-proponer.
* Ante `409` por stack en progreso, espera y reintenta — no crees un stack paralelo.
* Para modificar un stack existente propón un changeset; no elimines y recrees.
# Primeros pasos (/docs/cortex/stacks/getting-started)
En esta guía creas un stack mínimo — una cuenta de proveedor, un modelo y un agente — y lo
despliegas. Sirve igual si eres una persona usando el portal o un agente de IA trabajando
por API: cada paso muestra ambas rutas.
## 0. Lo que necesitas [#0-lo-que-necesitas]
* Acceso al **portal de Cortex** con tu organización, o una **API key** del tenant con los
scopes `iac:manage` (validar y proponer) e `iac:deploy` (ejecutar).
* La API key del proveedor de LLM que va a usar tu agente (por ejemplo, una key de OpenAI).
Para los ejemplos por API define estas variables:
```bash
export CORTEX_API_URL="https://cortex.opensolvex.co" # http://localhost:3006 en desarrollo
export CORTEX_API_KEY="osx_sk_xxx" # tu key con scopes iac:*
export OPENAI_API_KEY="sk-xxx" # la key del proveedor de LLM
```
Ejecutar un stack puede crear cuentas con credenciales y alterar agentes y hooks de todo
el tenant. Emite keys dedicadas y rotables para CI con ese scope, y no lo incluyas en keys
de uso general.
## 1. Escribe el template [#1-escribe-el-template]
Guarda esto como `template.yaml`. Declara los tres recursos y cómo se conectan (`!Ref`), y
recibe la API key del proveedor por un parámetro `noEcho` — nunca la escribas en el YAML:
```yaml title="template.yaml"
version: "2026-07"
description: Mi primer agente en Cortex
parameters:
OpenaiApiKey:
type: string
noEcho: true
resources:
Cuenta:
type: Cortex::ProviderAccount
properties:
provider: openai
label: principal
apiKey: !Ref OpenaiApiKey
Modelo:
type: Cortex::Model
properties:
accountId: !Ref Cuenta
modelName: gpt-4o
Asistente:
type: Cortex::Agent
properties:
name: mi-asistente
systemPrompt: Eres el asistente de soporte de mi empresa. Responde breve y en español.
modelId: !Ref Modelo
outputs:
agentId: !GetAtt Asistente.id
```
## 2. Valida el template [#2-valida-el-template]
La validación es un dry-run puro: no crea nada. Revisa sintaxis, tipos de recurso,
referencias, ciclos y las propiedades de cada recurso contra su gestor.
**Portal:** el editor valida en vivo mientras escribes (o con ⌘S).
**API:**
```bash
curl -X POST "$CORTEX_API_URL/v1/iac/templates/validate" \
-H "x-api-key: $CORTEX_API_KEY" \
-H "Content-Type: application/json" \
-d "$(jq -n --rawfile t template.yaml '{template: $t}')"
```
Si es válido responde `200` con los recursos detectados y el orden de despliegue; si no,
`422` con la lista de errores y la ruta exacta de cada uno (`resources.Modelo.properties…`).
## 3. Crea el stack [#3-crea-el-stack]
Crear el stack **no materializa nada todavía**: queda en estado `REVIEW_IN_PROGRESS` con un
changeset pendiente que describe todo lo que se va a crear. El nombre del stack va en
kebab-case (`mi-primer-stack`) y es único en tu tenant.
**Portal:** *Stacks → Nuevo stack*. Elige una plantilla de la galería o pega la tuya, ponle
nombre, completa los parámetros (los `noEcho` se piden como contraseña) y crea. Aterrizas en
el detalle, en la pestaña de changesets.
**API:**
```bash
curl -X POST "$CORTEX_API_URL/v1/iac/stacks" \
-H "x-api-key: $CORTEX_API_KEY" \
-H "Content-Type: application/json" \
-d "$(jq -n --rawfile t template.yaml \
'{name: "mi-primer-stack", template: $t, parameters: {OpenaiApiKey: env.OPENAI_API_KEY}}')"
```
La respuesta trae el `stack` (guarda su `id`) y el `changeSet` inicial (guarda su `id`
también). Los valores `noEcho` que enviaste no vuelven en ninguna respuesta.
## 4. Revisa el diff [#4-revisa-el-diff]
El changeset lista cada operación: `add`, `modify`, `replace` o `remove`, con los campos que
cambian. Para un stack nuevo verás tres `add` en orden: `Cuenta`, `Modelo`, `Asistente`.
```bash
curl "$CORTEX_API_URL/v1/iac/stacks/$STACK_ID/change-sets" \
-H "x-api-key: $CORTEX_API_KEY"
```
## 5. Ejecuta el changeset [#5-ejecuta-el-changeset]
**Portal:** botón *Ejecutar* en el changeset pendiente (si hay cambios destructivos, te pide
confirmarlos explícitamente).
**API:**
```bash
curl -X POST "$CORTEX_API_URL/v1/iac/stacks/$STACK_ID/change-sets/$CHANGESET_ID/execute" \
-H "x-api-key: $CORTEX_API_KEY"
```
Responde `202`: el despliegue es **asíncrono**. Cortex aplica los recursos uno a uno en
orden topológico; si alguno falla, revierte automáticamente lo ya aplicado.
## 6. Espera el resultado [#6-espera-el-resultado]
Consulta el stack hasta que salga de `CREATE_IN_PROGRESS`:
```bash
curl "$CORTEX_API_URL/v1/iac/stacks/$STACK_ID" -H "x-api-key: $CORTEX_API_KEY"
```
* `CREATE_COMPLETE` — listo. El campo `outputs` trae tu `agentId`, y `resources` mapea cada
nombre lógico a su id físico.
* `ROLLBACK_COMPLETE` — algo falló y se revirtió. Mira `statusReason` y la traza de eventos:
```bash
curl "$CORTEX_API_URL/v1/iac/stacks/$STACK_ID/events" -H "x-api-key: $CORTEX_API_KEY"
```
En el portal, la pestaña *Eventos* muestra esta traza en vivo (estilo CloudFormation) y la
pestaña *Recursos* enlaza cada recurso a su pantalla nativa.
## 7. Actualiza el stack [#7-actualiza-el-stack]
Para cambiar algo, edita el template y propón un nuevo changeset — nunca se aplica directo:
```bash
curl -X POST "$CORTEX_API_URL/v1/iac/stacks/$STACK_ID/change-sets" \
-H "x-api-key: $CORTEX_API_KEY" \
-H "Content-Type: application/json" \
-d "$(jq -n --rawfile t template.yaml '{template: $t}')"
```
Revisa el diff y ejecútalo igual que antes. Re-aplicar el mismo template produce un diff
vacío: el motor es idempotente.
## Siguientes pasos [#siguientes-pasos]
* Agrega [secretos, tools, skills, hooks y enrutamiento](/docs/cortex/stacks/resources) a tu
template para un agente completo.
* Entiende los [estados, el rollback y la eliminación con `retain`](/docs/cortex/stacks/lifecycle).
* Si integras desde CI o desde un agente, revisa la
[referencia completa de la API](/docs/cortex/stacks/api).
# Stacks (IaC) (/docs/cortex/stacks)
Los **stacks** te permiten definir la configuración completa de tu tenant de Cortex como
código, al estilo de AWS CloudFormation: escribes un **template YAML** que describe tus
recursos (cuentas de proveedor, modelos, secretos, skills, fuentes de tools, agentes, reglas
de enrutamiento y hooks) y Cortex lo materializa por ti, en el orden correcto y con rollback
automático si algo falla.
## Los tres conceptos [#los-tres-conceptos]
**Template.** Un documento YAML con tres secciones: `parameters` (entradas, incluidas las
sensibles marcadas `noEcho`), `resources` (los recursos a materializar) y `outputs` (valores
que el stack expone al terminar). Las referencias entre recursos se declaran con `!Ref`,
`!GetAtt` y `!Sub`, y de ellas Cortex deriva el grafo de dependencias y el orden de
despliegue. Ver la [referencia de templates](/docs/cortex/stacks/templates).
**Stack.** La instancia de un template en tu tenant. Guarda el template aplicado, los
parámetros resueltos (los sensibles quedan redactados), el mapeo de cada nombre lógico a su
recurso físico y el estado del despliegue (`CREATE_COMPLETE`, `UPDATE_IN_PROGRESS`…).
**ChangeSet.** El diff calculado entre lo que hay y lo que el template propone: qué recursos
se agregan, cuáles se modifican, cuáles se recrean y cuáles se eliminan. **Nada se aplica
sin un changeset aprobado**: primero revisas el diff y luego lo ejecutas explícitamente.
## Garantías del motor [#garantías-del-motor]
* **Los secretos jamás viajan en el template.** Los valores sensibles (API keys, tokens)
entran siempre por parámetros `noEcho` y quedan redactados en todo lo que el sistema
persiste o expone. Ver [Seguridad](/docs/cortex/stacks/security).
* **Idempotencia por adopción.** Si un recurso equivalente ya existe en tu tenant (misma
clave natural, p. ej. un secreto con el mismo slug), el stack lo adopta en lugar de
duplicarlo. Aplicar dos veces el mismo template produce un changeset vacío.
* **Rollback automático.** Si un recurso falla a mitad del despliegue, Cortex revierte lo ya
aplicado y deja el tenant en el estado anterior.
* **Sin lógica duplicada.** El motor crea, actualiza y elimina llamando a los mismos
servicios que usa el portal, así que un recurso creado por stack es idéntico a uno creado
a mano — y puedes seguir gestionándolo desde su pantalla nativa.
## Cómo se usa [#cómo-se-usa]
Puedes trabajar desde el **portal de Cortex** (sección *Stacks*: galería de plantillas,
editor con validación en vivo, diff visual y traza de eventos) o por **API REST**, ideal
para CI y para agentes de IA que crean stacks por ti.
# Ciclo de vida (/docs/cortex/stacks/lifecycle)
Un stack evoluciona siempre por el mismo camino: propones un changeset, revisas el diff y lo
ejecutas. Esta página describe cada fase y sus garantías.
## Estados del stack [#estados-del-stack]
| Estado | Significado |
| ------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
| `REVIEW_IN_PROGRESS` | Recién creado; nada materializado, hay un changeset por ejecutar |
| `CREATE_IN_PROGRESS` / `CREATE_COMPLETE` / `CREATE_FAILED` | Primer despliegue |
| `ROLLBACK_IN_PROGRESS` / `ROLLBACK_COMPLETE` | El primer despliegue falló y se revirtió |
| `UPDATE_IN_PROGRESS` / `UPDATE_COMPLETE` | Actualización en curso / aplicada |
| `UPDATE_ROLLBACK_IN_PROGRESS` / `UPDATE_ROLLBACK_COMPLETE` / `UPDATE_ROLLBACK_FAILED` | Actualización revertida (o el rollback mismo falló) |
| `DELETE_IN_PROGRESS` / `DELETE_COMPLETE` / `DELETE_FAILED` | Eliminación |
Cualquier estado `*_IN_PROGRESS` (salvo `REVIEW_IN_PROGRESS`) actúa como **mutex**: mientras
dura, proponer otro changeset, ejecutar o eliminar responde `409 Conflict`.
## Changesets [#changesets]
Un changeset pasa por: `pending` → `executing` → `executed` | `failed` | `rolled_back`.
Proponer un changeset nuevo marca `superseded` al `pending` anterior (nunca a uno que ya
está ejecutándose). El historial completo se conserva en el stack.
El diff clasifica cada recurso en una acción:
* **`add`** — no existe (o se adopta uno equivalente por clave natural).
* **`modify`** — cambian propiedades declarables, o un valor write-only detectable (p. ej.
el valor de un secreto, por huella).
* **`replace`** — cambia una propiedad inmutable; el recurso se **recrea**. Según la
propiedad, el motor crea primero el nuevo y luego borra el viejo (*create-first*, cuando
cambia la clave natural) o borra y recrea (*delete-first*). Los recursos que dependen de
uno reemplazado entran como `modify` para re-apuntar sus referencias.
* **`remove`** — salió del template; se elimina al ejecutar.
Re-proponer el mismo template produce un diff con **cero entradas**: no hay nada que
ejecutar.
## Ejecución [#ejecución]
Ejecutar un changeset es asíncrono (la API responde `202`). El motor corre una función
durable que aplica un paso por recurso en **orden topológico** (dependencias primero):
1. Busca el recurso por su clave natural; si existe, lo **adopta** y actualiza; si no, lo
crea.
2. Toma un snapshot previo del recurso (para poder revertir).
3. Materializa los valores `noEcho` **solo en memoria** durante la llamada al gestor.
4. Registra el resultado en la traza de eventos del stack.
Al final elimina los `remove` en orden inverso, limpia los físicos viejos de los `replace` y
resuelve los `outputs`. Los parámetros sensibles transitorios y los snapshots se purgan al
cerrar el changeset.
## Rollback automático [#rollback-automático]
Si cualquier recurso falla al aplicarse, el stack entra en `ROLLBACK_IN_PROGRESS` (o
`UPDATE_ROLLBACK_IN_PROGRESS`) y revierte las operaciones ya aplicadas en orden inverso:
* Un recurso **creado** (no adoptado) se elimina.
* Un recurso **adoptado o modificado** se restaura desde su snapshot — incluida la rotación
de credenciales y valores de secretos, que vuelven a su valor anterior sin exponerse.
* Un **replace** revierte el lado que alcanzó a ejecutarse.
El stack termina en `ROLLBACK_COMPLETE` / `UPDATE_ROLLBACK_COMPLETE` con el tenant en su
estado previo. La traza de eventos registra el motivo del fallo recurso por recurso. Desde
ahí puedes corregir el template y proponer un nuevo changeset.
## Traza de eventos [#traza-de-eventos]
Cada paso del despliegue emite un evento append-only con recurso, estado, motivo y **actor**
(el usuario del portal o la API key que ejecutó). En el portal es la pestaña *Eventos*, que
se refresca sola mientras el stack está en progreso; por API es
`GET /v1/iac/stacks/:id/events`.
## Drift y export [#drift-y-export]
Con el tiempo, alguien puede modificar a mano un recurso gestionado por el stack.
`POST /v1/iac/stacks/:id/detect-drift` (o el botón del portal) compara lo aplicado con lo
que existe y marca cada recurso `IN_SYNC`, `DRIFTED` o `DELETED`, con el diff por propiedad
(los valores write-only se comparan por huella, sin exponerse). Además,
`GET /v1/iac/export` genera un template a partir de los recursos existentes del tenant —
útil para adoptar como código lo que ya creaste a mano.
## Eliminar un stack [#eliminar-un-stack]
Eliminar es asíncrono y también pasa por confirmación explícita:
* Por defecto se eliminan **todos** los recursos del stack, en orden topológico inverso
(primero quienes referencian, luego los referenciados).
* Con `retain` indicas nombres lógicos que deben **sobrevivir**: quedan desasociados del
stack pero intactos en tu tenant. En el portal, el diálogo de eliminación muestra un
checkbox por recurso.
```bash
curl -X DELETE "$CORTEX_API_URL/v1/iac/stacks/$STACK_ID" \
-H "x-api-key: $CORTEX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"retain": ["SkillCalificar"]}'
```
El stack termina en `DELETE_COMPLETE` y desaparece de la lista, pero su historial de
changesets y eventos se conserva para auditoría.
# Observadores por defecto (/docs/cortex/stacks/observers-default)
Cortex versiona en el repo un stack IaC con los **observadores "de fábrica"**
(`apps/cortex/stacks/observadores-default.yaml`): agentes que leen conversaciones
terminadas y persisten Insights del contacto, sin responder nunca por el canal.
| Recurso | Qué hace | Cadencia por defecto |
| ---------------------------------------- | ----------------------------------------------------------------------- | --------------------------------------------- |
| `observador-insights-contacto` + su hook | Mantiene la ficha del contacto (Insights `contact_info` y `preference`) | Cada **3 sesiones** de una misma conversación |
| `observador-oportunidades` + su hook | Detecta oportunidades comerciales, decisiones y sentimiento | Cada **5 sesiones** |
Con sesión-por-turno, cada turno del contacto es una sesión: los hooks usan la
condición `everyNSessions` sobre `session.ended`.
## Requisito [#requisito]
Un **modelo ya registrado** en el tenant (`mdl_…`): es el único parámetro
obligatorio (`ModelId`). Las cadencias (`CadenciaInsightsContacto`,
`CadenciaOportunidades`) tienen default y son opcionales.
## Opción A — portal (recomendada) [#opción-a--portal-recomendada]
1. Ve a **Stacks → Nuevo stack**: la plantilla **«Observadores por defecto»**
aparece destacada en la galería.
2. Elige el modelo en el parámetro `ModelId` (el formulario lista los modelos
del tenant) y ajusta las cadencias si quieres.
3. Crea el stack y **ejecuta el changeset inicial**. Los dos agentes quedan
marcados como observadores y sus hooks activos.
## Opción B — API [#opción-b--api]
```bash
TEMPLATE=$(cat apps/cortex/stacks/observadores-default.yaml)
curl -s -X POST "$CORTEX_API_URL/v1/iac/stacks" \
-H "x-api-key: $CORTEX_API_KEY" -H "content-type: application/json" \
-d "$(jq -n --arg t "$TEMPLATE" \
'{name: "observadores-default", template: $t, parameters: {ModelId: "mdl_xxx"}}')"
# → ejecutar el changeset inicial:
curl -s -X POST "$CORTEX_API_URL/v1/iac/stacks//change-sets//execute" \
-H "x-api-key: $CORTEX_API_KEY"
```
## Opción C — CLI `osx` (fabric) [#opción-c--cli-osx-fabric]
Desde un directorio con el template (o apuntando a él con `-t`):
```bash
osx stacks deploy observadores-default \
-t apps/cortex/stacks/observadores-default.yaml \
--param ModelId=mdl_xxx
```
La CLI valida, muestra el diff, pide confirmación y espera el resultado. En CI
añade `--yes` y pasa la key por `OSX_API_KEY`.
## Actualizaciones y personalización [#actualizaciones-y-personalización]
* **Converger una versión nueva del YAML**: repite el deploy (portal o
`osx stacks deploy`) sobre el mismo stack — el motor propone un changeset
`UPDATE` con el diff exacto y nada cambia hasta ejecutarlo.
* **Personalizaciones del tenant**: si editas los agentes u hooks por fuera del
stack, la [detección de drift](/docs/cortex/stacks/lifecycle) (CX-E19) lo
marca `DRIFTED` antes de pisarlo; ajusta el template o acepta la
convergencia conscientemente.
Desplegar este stack automáticamente al registrar el primer modelo del tenant
quedó como evolución futura (issue #72): hoy el despliegue es explícito por
galería, API o CLI.
# Tipos de recurso (/docs/cortex/stacks/resources)
Un stack puede declarar ocho tipos de recurso. Cada uno delega en el mismo gestor que usa el
portal, así que el resultado es idéntico a crearlo a mano.
## Cómo leer las propiedades [#cómo-leer-las-propiedades]
Cada propiedad tiene una semántica de cambio:
* **Declarable** — se compara en el diff; cambiarla produce un `modify`.
* **Inmutable** — no se puede modificar en el recurso existente; cambiarla produce un
`replace` (el recurso se recrea).
* **Write-only** — entra al sistema pero nunca se puede volver a leer (credenciales). En
templates se pasa siempre como `!Ref` a un parámetro `noEcho`, nunca como literal.
**Adopción por clave natural.** Casi todos los tipos tienen una clave natural (única por
tenant). Si al desplegar ya existe un recurso con esa clave, el stack lo **adopta** y lo
actualiza en lugar de duplicarlo. Eso hace idempotente re-aplicar un template.
***
## Cortex::ProviderAccount [#cortexprovideraccount]
Una cuenta de un proveedor de LLM (OpenAI, Anthropic…) con su credencial.
```yaml
CuentaOpenai:
type: Cortex::ProviderAccount
properties:
provider: openai # inmutable
label: principal # inmutable
apiKey: !Ref OpenaiApiKey # write-only, obligatorio !Ref a parámetro noEcho
```
| Propiedad | Semántica | Descripción |
| ---------- | ---------- | -------------------------------------------------- |
| `provider` | inmutable | Proveedor soportado (`openai`, `anthropic`…) |
| `label` | inmutable | Etiqueta que distingue cuentas del mismo proveedor |
| `apiKey` | write-only | Credencial; un literal aquí es error de validación |
Clave natural: `(provider, label)`. Un update solo puede **rotar la credencial** —
`provider` o `label` distintos recrean la cuenta.
## Cortex::Model [#cortexmodel]
Un modelo disponible sobre una cuenta de proveedor.
```yaml
ModeloVentas:
type: Cortex::Model
properties:
accountId: !Ref CuentaOpenai
modelName: gpt-4o
```
| Propiedad | Semántica | Descripción |
| ----------- | ---------- | ----------------------------------------------------------- |
| `accountId` | declarable | `!Ref` a un `Cortex::ProviderAccount` (o id físico `pac_…`) |
| `modelName` | declarable | Nombre del modelo en el proveedor |
Clave natural: `(accountId, modelName)`.
## Cortex::Secret [#cortexsecret]
Una credencial de negocio (API key de un CRM, token de un servicio) que agentes y tools
consumen sin ver el plaintext.
```yaml
CrmApiKey:
type: Cortex::Secret
properties:
slug: crm-api-key # inmutable
description: API key del CRM
value: !Ref CrmApiKeyValue # write-only, obligatorio !Ref a parámetro noEcho
```
| Propiedad | Semántica | Descripción |
| ------------- | ---------- | ------------------------------------------------ |
| `slug` | inmutable | Identificador estable del secreto en el tenant |
| `description` | declarable | Descripción legible |
| `value` | write-only | El valor; un literal aquí es error de validación |
Clave natural: `(slug)`. Aunque el valor no se puede leer, Cortex detecta que **cambió**
(por huella criptográfica) y lo rota con un `modify`, sin exponerlo jamás.
## Cortex::Skill [#cortexskill]
Una instrucción reutilizable (documento `SKILL.md`) que los agentes cargan.
```yaml
SkillCalificar:
type: Cortex::Skill
properties:
content: |
---
name: calificar-leads
description: Califica leads entrantes
---
Cuando llegue un lead nuevo...
```
| Propiedad | Semántica | Descripción |
| --------- | -------------------- | ---------------------------------------------------- |
| `content` | declarable | El `SKILL.md` completo, frontmatter incluido |
| `name` | inmutable (derivada) | Se extrae del frontmatter; cambiarlo recrea la skill |
Clave natural: `(name)`. Las versiones de una skill son append-only: cada cambio de
`content` publica una versión nueva.
## Cortex::ToolSource [#cortextoolsource]
Una fuente de herramientas externa, vía servidor MCP o especificación OpenAPI.
```yaml
CrmTools:
type: Cortex::ToolSource
properties:
kind: mcp # inmutable: mcp | openapi
name: crm
config: { url: https://crm.example.test/mcp }
secretId: !Ref CrmApiKey # credencial de la fuente, por secreto
tools:
buscar_cliente: { enabled: true, sensitive: false }
```
| Propiedad | Semántica | Descripción |
| ---------- | ---------- | ------------------------------------------------------------------------ |
| `kind` | inmutable | `mcp` u `openapi`; cambiarlo recrea la fuente |
| `name` | declarable | Nombre único de la fuente en el tenant |
| `config` | declarable | `{ url }` para MCP; `{ specUrl }` o `{ spec }` para OpenAPI |
| `auth` | declarable | Esquema de autenticación de la fuente |
| `secretId` | declarable | `!Ref` a un `Cortex::Secret` con la credencial |
| `tools` | declarable | Overrides por tool descubierta: `enabled`, `sensitive`, `serverInjected` |
Clave natural: `(name)`. Las entradas de `tools` se aplican **después del descubrimiento**
de la fuente; referenciar un nombre de tool que no existe hace fallar el recurso (y dispara
rollback). La credencial va siempre por `secretId` — no hay campo de credencial directa en
templates. Por depender de un destino externo, este tipo reintenta hasta 3 veces antes de
fallar.
## Cortex::Agent [#cortexagent]
El agente de IA: prompt, modelo y sus asociaciones a skills, tools y secretos.
```yaml
AgenteVentas:
type: Cortex::Agent
properties:
name: asistente-ventas
systemPrompt: Eres el asistente de ventas de ACME.
modelId: !Ref ModeloVentas
skills: [!Ref SkillCalificar]
tools:
- source: !Ref CrmTools
names: [buscar_cliente]
secretIds: [!Ref CrmApiKey]
```
| Propiedad | Semántica | Descripción |
| ------------------------------------- | ---------- | ------------------------------------------------------------------------------------------ |
| `name` | declarable | Nombre único del agente en el tenant |
| `description`, `avatar` | declarable | Presentación |
| `systemPrompt` | declarable | Instrucciones base |
| `modelId` | declarable | `!Ref` a un `Cortex::Model` |
| `maxIterations`, `timezone`, `locale` | declarable | Comportamiento y localización |
| `skills` | declarable | Lista de `!Ref` a `Cortex::Skill`; se converge por diff |
| `tools` | declarable | Lista de `{ source: !Ref, names: [...] }`; los nombres se resuelven tras el descubrimiento |
| `secretIds` | declarable | Allowlist de secretos accesibles (default-deny, semántica de reemplazo total) |
Clave natural: `(name)`. Las asociaciones (`skills`, `tools`, `secretIds`) se convergen por
diff: el stack agrega y quita asociaciones hasta igualar lo declarado.
## Cortex::RoutingRule [#cortexroutingrule]
Una regla que enruta tráfico entrante hacia un agente.
```yaml
ReglaVentas:
type: Cortex::RoutingRule
properties:
priority: 10
match: { channelType: whatsapp }
agentId: !Ref AgenteVentas
enabled: true
```
| Propiedad | Semántica | Descripción |
| ---------- | ---------- | ------------------------------------- |
| `priority` | declarable | Orden de evaluación (menor = primero) |
| `match` | declarable | Condiciones de coincidencia |
| `agentId` | declarable | `!Ref` al agente destino |
| `enabled` | declarable | Activa/inactiva (default `true`) |
**Sin clave natural**: las reglas no se adoptan. Una regla declarada en el template es
propiedad del stack; reglas equivalentes creadas a mano no se fusionan.
## Cortex::Hook [#cortexhook]
Una reacción a eventos del sistema: despachar un agente o llamar un HTTP endpoint.
```yaml
HookObservador:
type: Cortex::Hook
properties:
name: observador-ventas
eventType: session.ended
action: { kind: dispatch_agent, agentId: !Ref AgenteVentas }
```
| Propiedad | Semántica | Descripción |
| ------------------------------------------------ | ---------- | ----------------------------------------------------------------------------------------------- |
| `name` | declarable | Nombre único del hook en el tenant |
| `eventType` | declarable | Evento que lo dispara |
| `conditions` | declarable | Filtros adicionales |
| `action` | declarable | `{ kind: dispatch_agent, agentId }` o `{ kind: http_call, url, method?, headers?, timeoutMs? }` |
| `payloadScope`, `enabled`, `priority`, `agentId` | declarable | Alcance del payload y comportamiento |
Clave natural: `(name)`. La eliminación de un hook es definitiva (sin papelera).
***
## Template completo de ejemplo [#template-completo-de-ejemplo]
Los ocho tipos conectados — el mismo grafo que usa la suite de pruebas del motor:
```yaml title="template-completo.yaml"
version: "2026-07"
description: Agente de ventas completo con CRM
parameters:
OpenaiApiKey: { type: string, noEcho: true }
CrmApiKeyValue: { type: string, noEcho: true }
resources:
CuentaOpenai:
type: Cortex::ProviderAccount
properties: { provider: openai, label: principal, apiKey: !Ref OpenaiApiKey }
ModeloVentas:
type: Cortex::Model
properties: { accountId: !Ref CuentaOpenai, modelName: gpt-4o }
CrmApiKey:
type: Cortex::Secret
properties: { slug: crm-api-key, description: API key del CRM, value: !Ref CrmApiKeyValue }
CrmTools:
type: Cortex::ToolSource
properties:
kind: mcp
name: crm
config: { url: https://crm.example.test/mcp }
secretId: !Ref CrmApiKey
tools:
buscar_cliente: { enabled: true, sensitive: false }
SkillCalificar:
type: Cortex::Skill
properties:
content: |
---
name: calificar-leads
description: Califica leads entrantes
---
Cuando llegue un lead nuevo, pide presupuesto y plazo antes de derivar.
AgenteVentas:
type: Cortex::Agent
properties:
name: asistente-ventas
systemPrompt: Eres el asistente de ventas de ACME.
modelId: !Ref ModeloVentas
skills: [!Ref SkillCalificar]
tools:
- source: !Ref CrmTools
names: [buscar_cliente]
secretIds: [!Ref CrmApiKey]
HookObservador:
type: Cortex::Hook
properties:
name: observador-ventas
eventType: session.ended
action: { kind: dispatch_agent, agentId: !Ref AgenteVentas }
ReglaVentas:
type: Cortex::RoutingRule
properties:
priority: 10
match: { channelType: whatsapp }
agentId: !Ref AgenteVentas
outputs:
agentId: !GetAtt AgenteVentas.id
```
En el portal, la galería de *Nuevo stack* incluye variantes listas de este patrón (agente de
ventas con OpenAPI, agente MCP, catálogo de modelos, observabilidad con hooks HTTP).
# Seguridad (/docs/cortex/stacks/security)
El módulo de stacks toca las piezas más sensibles del tenant: credenciales de proveedores,
secretos de negocio, agentes y hooks. Estas son sus garantías y lo que te toca a ti.
## Secretos: nunca en el template [#secretos-nunca-en-el-template]
Un template es código: se versiona, se comparte, se pega en chats. Por eso **ningún valor
sensible puede escribirse en él** — el validador rechaza literales en los campos de
credencial (`apiKey` de una cuenta, `value` de un secreto). El flujo correcto:
1. Declara un parámetro con `noEcho: true`.
2. Referéncialo con `!Ref` desde el campo write-only.
3. Pasa el valor real en `parameters` al crear el stack o proponer el changeset.
A partir de ahí, el valor solo existe **en memoria** durante el despliegue. En todo lo
demás — el stack persistido, los changesets, la traza de eventos, las respuestas de la API,
la UI — aparece como `***noEcho***`. Durante la ventana entre proponer y ejecutar, el valor
se guarda cifrado y se purga al cerrar el changeset. Los cambios de valor se detectan por
huella criptográfica, nunca comparando el plaintext, y un output que intente exponer un
parámetro `noEcho` es un error de validación.
El template no lleva secretos, pero sí describe tu arquitectura (agentes, prompts, tools,
endpoints internos). Compártelo con el mismo criterio que el resto de tu código.
## Scopes: deploy es administrar el tenant [#scopes-deploy-es-administrar-el-tenant]
Los tres scopes (`iac:read`, `iac:manage`, `iac:deploy`) están pensados para separar
responsabilidades: quien revisa no necesita poder ejecutar. Ten presente que **`iac:deploy`
equivale a administrar el tenant completo** — un changeset puede crear cuentas con
credenciales, reescribir prompts de agentes y registrar hooks que llaman URLs externas.
Recomendaciones:
* Para CI, emite una API key dedicada con `iac:deploy`, rótala periódicamente y revócala
ante cualquier sospecha.
* Para agentes de IA que operan stacks, dales `iac:manage` por defecto y reserva
`iac:deploy` para flujos con aprobación humana del changeset.
* No mezcles `iac:deploy` en keys de uso general del producto.
## Aislamiento multi-tenant [#aislamiento-multi-tenant]
* Todo stack, changeset y recurso vive en un tenant; los ids de otros tenants no existen
para ti: referenciar un id físico ajeno falla la validación con "no existe o no es
visible".
* Los recursos de plataforma (compartidos) son de solo lectura desde stacks.
* Las API keys de tenant fijan el tenant por sí mismas; el campo `tenantId` del body se
ignora para ellas.
## Robustez del parser [#robustez-del-parser]
Los templates se parsean en modo seguro: sin tags YAML personalizados más allá de `!Ref` /
`!GetAtt` / `!Sub`, sin merge keys, con alias limitados y límites duros de tamaño (512 KB,
200 recursos, 20 000 nodos). Un template hostil o malformado se rechaza antes de tocar
cualquier gestor.
## Auditoría [#auditoría]
La traza de eventos de cada stack es **append-only** y cada entrada registra el actor que la
provocó — el usuario del portal (`user:…`) o la API key (`api-key:…`) — incluso tras
eliminar el stack. Los eventos nunca contienen valores sensibles.
## Concurrencia segura [#concurrencia-segura]
Un stack en despliegue actúa como mutex: intentos concurrentes de ejecutar, proponer o
eliminar reciben `409` en lugar de pisarse. Proponer un changeset nuevo reemplaza
(`superseded`) al pendiente anterior, pero jamás a uno en ejecución.
# Referencia de templates (/docs/cortex/stacks/templates)
Un template es un documento YAML con hasta cinco claves de nivel superior. Solo `resources`
es obligatoria; cualquier sección desconocida es un error de validación.
```yaml
version: "2026-07" # opcional, informativo
description: Qué hace # opcional
parameters: { ... } # entradas del template
resources: { ... } # los recursos a materializar
outputs: { ... } # valores que expone el stack
```
## Parámetros [#parámetros]
Cada parámetro declara su tipo y, opcionalmente, valor por defecto, valores permitidos y si
es sensible:
```yaml
parameters:
Ambiente:
type: string # string | number | boolean
description: Ambiente objetivo
default: sandbox
allowedValues: [sandbox, production]
CrmApiKey:
type: string
noEcho: true # sensible: nunca se persiste ni se muestra
```
* `noEcho: true` marca el parámetro como sensible: su valor solo existe en memoria durante
el despliegue y queda redactado (`***noEcho***`) en todo lo persistido y en toda
respuesta de la API. Un parámetro `noEcho` **no puede tener `default`**.
* Los valores se pasan al crear el stack o al proponer un changeset (`parameters` en el
body, o el formulario del portal).
## Recursos [#recursos]
Cada recurso tiene un **nombre lógico** (la clave), un `type` de la familia `Cortex::*` y
sus `properties`. Las dependencias se derivan automáticamente de las referencias; `dependsOn`
existe para forzar orden cuando no hay referencia explícita:
```yaml
resources:
CrmTools:
type: Cortex::ToolSource
properties:
kind: mcp
name: crm
config: { url: https://crm.example.test/mcp }
dependsOn: [OtroRecurso] # opcional
```
Los ocho tipos disponibles y sus propiedades están en
[Tipos de recurso](/docs/cortex/stacks/resources).
**Nombres.** Nombres lógicos y de parámetro siguen `^[A-Za-z][A-Za-z0-9]{0,127}$`
(alfanuméricos, empiezan por letra). Un parámetro y un recurso no pueden compartir nombre —
haría ambiguo a `!Ref`.
## Funciones intrínsecas [#funciones-intrínsecas]
Tres funciones conectan el template:
| Función | Uso | Resultado |
| ------------------------ | ---------------------- | ----------------------------------------------------- |
| `!Ref Nombre` | parámetro o recurso | El valor del parámetro, o el id físico del recurso |
| `!GetAtt Recurso.attr` | atributo de un recurso | El atributo tras materializarse (p. ej. `.id`) |
| `!Sub "texto ${Nombre}"` | interpolación | El texto con `${Param}` y `${Recurso.attr}` resueltos |
```yaml
outputs:
agentId: !GetAtt Asistente.id
saludo:
value: !Sub "Agente ${Asistente.id} desplegado en ${Ambiente}"
description: Ejemplo de interpolación
```
Los outputs aceptan la forma corta (`nombre: !GetAtt X.id`) o la forma con
`value`/`description`. Un output no puede exponer un valor `noEcho`.
**Forma JSON equivalente.** Los tags YAML son azúcar sintáctico; la forma JSON es idéntica
tras normalizar, útil para templates generados por máquina:
```yaml
apiKey: !Ref OpenaiApiKey # equivale a:
apiKey: { Ref: OpenaiApiKey }
agentId: !GetAtt Asistente.id # equivale a:
agentId: { "Fn::GetAtt": [Asistente, id] }
texto: !Sub "hola ${Nombre}" # equivale a:
texto: { "Fn::Sub": "hola ${Nombre}" }
```
Ningún otro tag YAML personalizado se acepta: el parser corre en modo seguro (sin merge
keys, alias limitados).
## Orden de despliegue [#orden-de-despliegue]
De las referencias entre recursos Cortex construye un grafo dirigido acíclico y despliega en
orden topológico (las dependencias primero); al eliminar, usa el orden inverso. Un **ciclo
de referencias es un error de validación**, igual que un `!Ref` a un nombre inexistente o un
`dependsOn` roto.
También puedes referenciar por **id físico literal** (p. ej. `modelId: mdl_01H…`) un recurso
que ya existe en tu tenant y que no forma parte del stack. La validación verifica que exista
y sea visible para tu tenant; los ids de otros tenants se rechazan.
## Límites [#límites]
| Límite | Valor |
| --------------------- | ------ |
| Tamaño del template | 512 KB |
| Recursos por template | 200 |
| Nodos YAML | 20 000 |
## Errores de validación frecuentes [#errores-de-validación-frecuentes]
La validación (`POST /v1/iac/templates/validate`, o el editor del portal) devuelve una lista
de errores con la ruta exacta de cada uno. Los más comunes:
* Tipo de recurso desconocido, o sección de nivel superior desconocida.
* Nombre lógico o de parámetro que no cumple el patrón, o colisión parámetro↔recurso.
* `!Ref` / `!GetAtt` / `dependsOn` a un nombre que no existe; ciclos en el grafo.
* Propiedades que violan el contrato del gestor (campo faltante, tipo incorrecto).
* Una credencial escrita como literal donde va una referencia `noEcho` (`apiKey`, `value`).
* Un output que expone un parámetro `noEcho`.
* Id físico literal inexistente o de otro tenant.
* Template por encima de los límites de tamaño.