# Documentación de integración del Asixto Gateway

> Integración con los agentes de IA de Asixto, en dos etapas INDEPENDIENTES: (1) un servidor MCP que expone las operaciones de negocio de la empresa para que el agente las ejecute, y (2) la API del Gateway para iniciar conversaciones y recibir eventos. Se puede llamar a la API sin haber publicado ningún servidor: sin herramientas el agente conversa, pero no ejecuta nada en los sistemas de la empresa. Protocolo de la etapa 1: Model Context Protocol, revisión 2026-07-28 recomendada (transporte Streamable HTTP, sin estado); también se admite un servidor de la revisión anterior, que se registra con su era.

## Cómo usar este documento

Este archivo es la documentación completa de integración de Asixto Gateway, concatenada en orden.
Describe **qué debe construir el equipo de la empresa cliente**: un servidor MCP que exponga sus
operaciones de negocio para que el agente de IA de Asixto las ejecute durante una conversación.

**Ese servidor y la API del Gateway son dos etapas INDEPENDIENTES, y la API no lo exige**: se puede
llamar a la API sin haber publicado ningún servidor (el agente conversa, pero no ejecuta ninguna
operación en los sistemas de la empresa) y se puede construir el servidor antes de tener credenciales
de la API. Lo que sigue es el trabajo de la etapa 1; la etapa 2 son las páginas 14 a 19.

Resumen del trabajo, en el orden en que conviene hacerlo:

1. Un endpoint HTTPS POST que hable Model Context Protocol con el SDK oficial del lenguaje del equipo
   (TypeScript, Python, Go o C#, línea v2). La revisión **recomendada** es 2026-07-28, que es *sin
   estado*. Si el servidor ya existe y habla una revisión anterior (con saludo `initialize`), **se
   admite igual**: la era se declara al dar de alta —y ahí se comprueba contra la URL de pruebas, así
   que declarar la que no habla el servidor rechaza el registro— y el Gateway habla la que
   corresponda. No hay que migrar para certificar.
2. Métodos obligatorios: `tools/list` y `tools/call`. Además, según la era: `server/discover`
   anunciando `supportedVersions` (revisión 2026-07-28) **o** el saludo `initialize` devolviendo la
   revisión que soporta (anteriores). Una de las dos, no las dos.
3. Un catálogo mínimo de tres herramientas de solo lectura (información de la empresa, consulta de
   cliente y, si hay más de una sede, sedes cercanas). Después, por dominio: catálogo, clientes,
   casos, oportunidades y agenda.
4. Cada herramienta: nombre estable de 40 caracteres como máximo (`^[a-z0-9_]+$`), `inputSchema`
   cerrado (`additionalProperties: false`) con **todo parámetro opcional aceptando `null`**,
   `required` reducido a lo imprescindible, `outputSchema`, anotaciones `readOnlyHint` /
   `destructiveHint` / `idempotentHint`, y una descripción de 700 caracteres como máximo redactada
   para que un modelo decida cuándo usarla.
5. Autenticación del endpoint: OAuth 2.1 con credenciales de cliente y metadatos de recurso
   protegido (RFC 9728), o un token rotable como mínimo aceptable.
6. Las seis garantías obligatorias en toda escritura: idempotencia con la clave que llega en el
   `_meta` de la llamada, propiedad del registro, guardas de estado terminal, autoridad del dato
   crítico en el sistema del cliente, salida mínima y saneada, y errores con `error_type` (uno de
   los seis canónicos) + `user_message`.

Restricciones que no se negocian: una operación por herramienta; sin identificadores internos como
entrada; respuesta de **3.000 caracteres** como máximo (ahí recorta el runtime); respetar el
`limit` recibido, hasta 25, y 10 si no viene; p95 objetivo de 2 segundos con corte a los 30; una
búsqueda sin resultados es un **éxito** con lista vacía, nunca un error.

Advertencias que cambian el diseño y suelen pasarse por alto: en llamada telefónica hay familias que
no se ejecutan (apertura de caso, nota interna, búsqueda web y escalado); el Gateway **no** envía
identidad autenticada del usuario final, así que la validación de propiedad se hace contra el
identificador que la propia conversación ya resolvió; el orden de dos herramientas en un mismo turno
no está garantizado, así que toda mutación revalida sus identificadores.

Los nombres de herramienta que aparecen en los ejemplos son ilustrativos: los define la empresa
cliente. Lo que está fijado es la forma, no los identificadores.

---

<!-- fuente: https://gateway.asixto.com/docs/README.md -->

# Documentación de integración del Asixto Gateway

Guía para equipos de ingeniería que van a conectar sus sistemas a los agentes de IA de
Asixto (chat omnicanal y llamadas telefónicas).

La integración tiene **dos etapas independientes**:

| Etapa | Dirección | Qué construye usted |
|---|---|---|
| **1. Servidor de herramientas** | Asixto → sus sistemas | Un servidor MCP que expone sus operaciones de negocio |
| **2. API del Gateway** | sus sistemas → Asixto | Llamadas a nuestra API para iniciar conversaciones y recibir eventos |

Esta documentación cubre **las dos**: la etapa 1 en las páginas 01 a 10, y la etapa 2 en las
páginas 14 a 17 —la referencia de la API— más el contrato de los **webhooks**
([página 11](./11-webhooks.md)). Las dos son independientes: puede desarrollar y probar su servidor
de herramientas —y su receptor de webhooks— antes de tener credenciales de la API, y puede llamar a
la API sin haber publicado ningún servidor.

> **Solo HTTP.** La API es JSON sobre HTTPS y no hay SDK que instalar ni versionar: cualquier cliente
> HTTP sirve, y los ejemplos son `curl` para que se puedan pegar en una terminal.

> **Dónde se da de alta su servidor.** El alta —**la URL de su servidor, la credencial, la URL de
> su entorno de pruebas y la era de protocolo que habla** ([página 09](./09-changelog.md))— se hace
> en la aplicación de Asixto. Cuando su servidor esté listo, recibe
> sus credenciales de acceso, registra esos cuatro datos y la certificación arranca desde ahí
> ([página 08](./08-certification.md)). **No necesita ese acceso para construir**: el orden
> recomendado es escribir el servidor, verificarlo usted mismo con la lista de la página 08 y
> avisarnos. Así la primera certificación pasa en la primera vuelta.

## Qué es la etapa 1

En una frase: **un servidor que el agente puede leer y escribir**. Consulta empresa, catálogo,
clientes, casos, agenda y embudo, y también abre casos, actualiza datos, agenda, reagenda, cancela y
mueve oportunidades. De las 25 herramientas del catálogo de referencia, 13 son de lectura y **12
mutan** su sistema.

Sus sistemas siguen siendo suyos, y el modelo de integración son tres pasos:

1. **Conectar.** Usted publica un **servidor MCP**
   ([Model Context Protocol](https://modelcontextprotocol.io), estándar abierto) y entrega a Asixto
   dos datos: **la URL y una credencial**.
2. **Instruir.** En Asixto configura su agente: cómo atiende, qué prioriza, qué hacer en cada
   situación. Esas instrucciones llegan al agente **junto con su catálogo de herramientas**
   ([página 04](./04-capabilities.md)).
3. **El agente resuelve.** Decide en la conversación qué herramienta llamar y con qué argumentos.
   Usted no programa flujos, no declara endpoints ni orquesta llamadas.

Lo único que conviene tener claro del tercer paso: el agente resuelve **mientras conversa**. Una
instrucción del tipo «antes de cerrar, registra el resultado» se cumple dentro de la conversación;
lo que ocurre después del último turno no lo decide el agente.

Lo que no hay que hacer: declarar endpoints uno por uno, mandarnos un OpenAPI para que lo
revisemos, o esperar una liberación nuestra cuando usted agregue una herramienta.

```
┌──────────────────────┐        ┌─────────────────┐        ┌──────────────────┐
│ Su servidor MCP      │  MCP   │ Asixto Gateway  │        │ Agente de IA     │
│ (sus tools sobre su  │◄──────►│ descubre,       │◄──────►│ chat y llamadas  │
│  backend / ERP / BD) │  HTTPS │ valida, ejecuta │        │ telefónicas      │
└──────────────────────┘        └─────────────────┘        └──────────────────┘
        usted                          Asixto                     Asixto
```

## Índice

| # | Página | Contenido |
|---|---|---|
| 01 | [Quickstart](./01-quickstart.md) | La primera hora: servidor mínimo funcionando y probado en local |
| 02 | [Contrato de herramientas](./02-tool-contract.md) | Referencia: entrada, salida, anotaciones, nombres |
| 03 | [Autenticación](./03-authentication.md) | OAuth 2.1, metadatos de recurso protegido, token rotable |
| 04 | [Catálogo requerido](./04-capabilities.md) | Qué exponer según lo que quiera que el agente resuelva |
| 05 | [Garantías obligatorias](./05-guarantees.md) | Idempotencia, propiedad, estado terminal, dato crítico |
| 06 | [Errores](./06-errors.md) | Forma del error y códigos que interpreta el agente |
| 07 | [Límites y rendimiento](./07-limits.md) | Latencia, tamaños, paginación, cuotas |
| 08 | [Certificación](./08-certification.md) | Comprobaciones automáticas antes de producción |
| 09 | [Versionado](./09-changelog.md) | Revisión del protocolo y política de cambios |
| 10 | [Operación y soporte](./10-operation.md) | Reintentos, concurrencia, caídas, pruebas con agente real, trazas y contacto |
| 11 | [Webhooks](./11-webhooks.md) | Etapa 2: firma, ventana anti-replay, deduplicación, catálogo de eventos y reintentos de entrega |
| 12 | [Precios](./12-pricing.md) | Lo que consume cada operación en créditos, los mínimos facturados y por qué el entorno de pruebas cuesta lo mismo |
| 13 | [Datos, retención y subencargados](./13-data.md) | Qué se guarda y cuánto, quién más ve su contenido, borrado a petición y lo que no se cobra |
| 14 | [La API: primeros pasos](./14-api-quickstart.md) | **Etapa 2**: entornos, el sobre, la clave de idempotencia y su primera llamada |
| 15 | [Referencia de operaciones](./15-api-reference.md) | Las ocho operaciones de `/v1` campo por campo, con ejemplos ejecutables, y los nueve permisos de una clave |
| 16 | [Errores de la API](./16-api-errors.md) | Los treinta códigos, con la acción que le toca a cada uno |
| 17 | [Trabajos y reintentos](./17-jobs.md) | Consulta de estado, retención del resultado y los cinco casos de una clave repetida |
| 18 | [Ritmo y cuotas](./18-api-limits.md) | Ritmo por credencial, cabeceras de saldo, concurrencia y cupo por entorno |

## Requisitos previos

- Un servidor HTTPS alcanzable desde internet, con certificado válido.
- Un entorno de pruebas separado de producción (la certificación escribe en él).
- Revisión del protocolo: **2026-07-28** recomendada. Los SDK oficiales de TypeScript, Python, Go y
  C# la implementan en su línea v2. Un servidor de la revisión anterior (saludo `initialize`) también
  se admite y se certifica igual: se declara su era al dar de alta
  ([página 09](./09-changelog.md)).

## Convenciones de esta documentación

- **DEBE**: requisito. Su ausencia bloquea la certificación.
- **DEBERÍA**: recomendado. Genera aviso, no bloquea.
- **PUEDE**: opcional.

Los nombres de herramienta de los ejemplos son ilustrativos: usted nombra las suyas. Lo que
está fijado es la forma, no los identificadores.

---

<!-- fuente: https://gateway.asixto.com/docs/01-quickstart.md -->

# 01 · Quickstart

**Lo que hay que construir al final es un servidor de lectura y escritura**: el agente consulta su
catálogo y sus clientes, pero también abre casos, actualiza datos, agenda y cancela citas y mueve
oportunidades. De las 25 herramientas del catálogo de referencia, **12 mutan** su sistema
([página 04](./04-capabilities.md)).

Esta página cubre solo **la primera hora**: una herramienta de lectura corriendo en local, para
validar que el protocolo, el esquema y las pruebas funcionan antes de escribir la lógica de negocio.
Conviene validar el protocolo con una sola herramienta antes de escribir las veinticinco.

El orden recomendado, y el que menos retrabajo produce, es el de tres fases de la
[página 04](./04-capabilities.md): primero 5 de lectura, después casos y actualización de cliente
(donde entran las garantías de escritura), y al final agenda y embudo.

> **Atajo recomendado.** Hay un **servidor de referencia ejecutable** que ya implementa todo el
> contrato (protocolo, autenticación, idempotencia, los seis errores) y trae la verificación de
> conformidad como comando:
> **<https://github.com/AndresTorresCode/asixto-mcp-reference>**. Se clona, se reemplaza el módulo
> que simula el sistema propio por las llamadas reales, y el resto del contrato ya está resuelto.
> Esta página explica lo que ese repo hace, para que pueda construirlo desde cero si lo prefiere.
>
> La verificación es un comando —`node scripts/conformance.mjs <url> <token> [--allow-writes]`— y
> corre contra **cualquier** servidor MCP, así que sirve para el suyo desde la primera herramienta.
> Detalle en la [página 08](./08-certification.md).

## 1. Instalar el SDK

Los SDK oficiales implementan el protocolo completo: descubrimiento, validación de esquemas,
serialización y transporte. Usted no escribe JSON-RPC.

```bash
# TypeScript / Node
npm install @modelcontextprotocol/server @modelcontextprotocol/express @modelcontextprotocol/node express zod

# Python
uv add "mcp[cli]"     # o: pip install "mcp[cli]"
```

## 2. Un servidor con una herramienta

### TypeScript con Streamable HTTP sobre Express

```ts
import { McpServer } from '@modelcontextprotocol/server';
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
import { createMcpExpressApp } from '@modelcontextprotocol/express';
import * as z from 'zod/v4';

const server = new McpServer({ name: 'acme-tools', version: '1.0.0' });

server.registerTool(
  'consultar_cliente',
  {
    description:
      'Busca un cliente por número de documento y devuelve sus datos de contacto y estado. ' +
      'Úsala para identificar a quién se está atendiendo. No la uses para modificar datos.',
    inputSchema: z.object({
      documento: z.string().min(5).max(20),
    }),
  },
  async ({ documento }) => {
    const cliente = await miBackend.buscarCliente(documento); // su API actual

    if (!cliente) {
      return {
        content: [{ type: 'text', text: 'No encontré un cliente con ese documento.' }],
        structuredContent: { encontrado: false },
      };
    }

    return {
      content: [{ type: 'text', text: `Cliente ${cliente.nombre}, estado ${cliente.estado}.` }],
      structuredContent: {
        encontrado: true,
        nombre: cliente.nombre,
        estado: cliente.estado,
        telefono: cliente.telefono,
      },
    };
  },
);

const app = createMcpExpressApp();

app.post('/mcp', async (req, res) => {
  // Sin estado: un transporte por petición. Es el modo que exige el Gateway.
  const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined });
  await server.connect(transport);
  await transport.handleRequest(req, res, req.body);
});

app.listen(8080);
```

### Python

```python
from mcp.server import MCPServer

mcp = MCPServer("acme-tools")


@mcp.tool()
def consultar_cliente(documento: str) -> dict:
    """Busca un cliente por documento y devuelve sus datos de contacto y estado.

    Úsala para identificar a quién se está atendiendo. No la uses para modificar datos.
    """
    cliente = mi_backend.buscar_cliente(documento)
    if cliente is None:
        return {"encontrado": False}
    return {
        "encontrado": True,
        "nombre": cliente.nombre,
        "estado": cliente.estado,
        "telefono": cliente.telefono,
    }
```

Las anotaciones de tipo **son** el esquema y el docstring **es** la descripción: no hay que
escribir JSON Schema a mano.

> **Antes de añadir el segundo parámetro, lea esto.** Todo parámetro **opcional** debe admitir
> `null` en el esquema final (`"type": ["string","null"]`, no `"type": "string"`). Si no, el día que
> el modelo mande `null` en vez de omitir el campo, el proveedor responde 400 y **el agente se queda
> sin respuesta en mitad de la conversación**. Revise el JSON Schema que genera su SDK, no solo el
> tipo del lenguaje, y compárelo con la [página 02](./02-tool-contract.md). Es el error más común y
> el más difícil de diagnosticar desde su lado, porque su servidor nunca recibe la llamada.

El SDK sirve stdio, Streamable HTTP y SSE. El Gateway solo consume **Streamable HTTP**;
la forma de exponerlo (servidor propio o montado en una app FastAPI existente) está en la
página *Running your server* de la documentación del SDK.

## 3. Probarlo en local

### Con el Inspector

```bash
uv run mcp dev server.py     # Python; abre el MCP Inspector
```

El Inspector lista sus herramientas y le permite invocarlas a mano. Es la forma más rápida
de verificar que el esquema es el que usted cree.

### Con un test automatizado

El mismo paquete es cliente, y puede conectarse al objeto servidor en memoria: sin puerto,
sin subproceso, sin transporte.

```python
import pytest
from mcp import Client

from server import mcp


@pytest.mark.anyio
async def test_consultar_cliente() -> None:
    async with Client(mcp) as client:
        result = await client.call_tool("consultar_cliente", {"documento": "CC1032"})
        assert result.structured_content["encontrado"] is True
```

Haga esto por cada herramienta antes de registrarla. La certificación de Asixto (página 08)
repite comprobaciones equivalentes contra su entorno de pruebas.

## 4. Añadir autenticación

Un servidor sin autenticación **no pasa la certificación**. Vea la
[página 03](./03-authentication.md); con el SDK de TypeScript son dos líneas
(`requireBearerAuth` y `mcpAuthMetadataRouter`).

## 5. Siguiente paso

1. Complete el núcleo obligatorio de tres herramientas: [página 04](./04-capabilities.md).
2. Aplique las garantías de la [página 05](./05-guarantees.md) a cualquier herramienta que
   escriba en sus sistemas.
3. Publíquelo en HTTPS, verifíquelo usted mismo con la lista de la
   [página 08](./08-certification.md) y avísenos: recibirá sus credenciales para dar de alta la URL
   y arrancar la certificación.

## Errores frecuentes en el primer intento

| Síntoma | Causa habitual |
|---|---|
| **El agente se queda sin responder a mitad de la conversación** | Un parámetro opcional con tipo estricto recibió `null`: el proveedor del modelo devuelve 400 y se cae toda la generación. Todo opcional debe admitir `null` ([página 02](./02-tool-contract.md)). |
| El agente no usa la herramienta | La descripción no dice *cuándo* usarla, o el nombre no se parece a la intención del usuario. |
| El agente inventa un parámetro | El esquema no está cerrado: falta `additionalProperties: false` o el campo no es un `enum`. |
| El agente pide datos que su sistema ya podría resolver | `required` demasiado largo. Déjelo en lo imprescindible. |
| El agente repite la operación | Falta idempotencia: la clave llega en `_meta` ([página 05](./05-guarantees.md)). |
| El agente dice «tengo un problema técnico» cuando simplemente no hay resultados | Devolvió `error` en vez de una lista vacía ([página 06](./06-errors.md)). |
| Respuesta truncada en la conversación | Devuelve demasiado. El recorte es a **3.000 caracteres**: [página 07](./07-limits.md). |
| La herramienta no se ejecuta en llamadas telefónicas | Hay familias bloqueadas en voz por diseño ([página 04](./04-capabilities.md)). |

---

<!-- fuente: https://gateway.asixto.com/docs/02-tool-contract.md -->

# 02 · Contrato de herramientas

Referencia de lo que el Gateway envía a cada herramienta y de lo que espera de vuelta.

## Métodos del protocolo

| Método | Exigencia | Uso desde el Gateway |
|---|---|---|
| `server/discover` | **DEBE** | Primera llamada. Anuncia versiones soportadas, capacidades e identidad. Con el SDK oficial, este método **solo lo instala la entrada HTTP moderna**: si monta el transporte a mano, su servidor responde `-32601` y no pasa la certificación. El servidor de referencia usa el camino correcto y lo documenta en el código. |
| `tools/list` | **DEBE** | Catálogo con esquemas. Al registrar, al notificar cambio y al expirar la caché (15 min). |
| `tools/call` | **DEBE** | Ejecución de una herramienta. |
| `subscriptions/listen` | PUEDE | Aviso `toolsListChanged` cuando cambia el catálogo. Sin esto, el refresco es por caducidad. |

El Gateway consume únicamente resultados **completos**. El patrón de varias vueltas
(`input_required`) **no** se consume: toda herramienta se resuelve en una sola llamada.

El campo `resultType` lo pone el SDK de la revisión `2026-07-28`; si su servidor es de una revisión
anterior y no lo emite, el Gateway lo trata como `complete`, que es lo que manda la propia
especificación (SEP-2322). O sea que la ausencia del campo **no** rechaza su respuesta: lo que se
rechaza es un `input_required` explícito.

## Transporte

- Un único endpoint **HTTPS POST**, certificado válido.
- **Sin estado**: la revisión 2026-07-28 eliminó las sesiones de protocolo y el saludo de
  inicialización. Cada petición es autónoma.
- Cabeceras estándar `Mcp-Method` y `Mcp-Name` en cada POST.
- **Sin reanudación de flujo**: si la conexión se corta antes de recibir la respuesta, el Gateway
  reintenta como petición nueva, con un identificador de petición nuevo y **la misma clave de
  idempotencia**. De ahí la exigencia de la garantía 1. Esto es distinto de un tiempo de espera
  agotado: ahí el Gateway no reintenta, informa al agente y la conversación sigue sin la
  herramienta.
- `tools/list` **DEBERÍA** devolver las herramientas en orden estable. El propio estándar lo
  recomienda: un orden cambiante invalida la caché del modelo y degrada la latencia.

## Definición de una herramienta

```json
{
  "name": "abrir_caso_soporte",
  "title": "Abrir caso de soporte",
  "description": "Registra un caso de soporte para un cliente identificado y devuelve su número de radicado. Úsala solo cuando el cliente ya describió el problema. No la uses para consultar el estado de un caso existente.",
  "inputSchema": {
    "type": "object",
    "additionalProperties": false,
    "required": ["documento", "tipo", "descripcion"],
    "properties": {
      "documento":   { "type": "string", "maxLength": 20 },
      "tipo":        { "enum": ["falla", "instalacion", "cobro"] },
      "descripcion": { "type": "string", "maxLength": 400 },
      "prioridad":   { "type": ["string", "null"], "enum": ["alta", "normal", null] }
    }
  },
  "outputSchema": {
    "type": "object",
    "required": ["radicado", "estado"],
    "properties": {
      "radicado":  { "type": "string" },
      "estado":    { "enum": ["abierto", "en_cola"] },
      "sla_horas": { "type": "integer" }
    }
  },
  "annotations": {
    "readOnlyHint": false,
    "destructiveHint": false,
    "idempotentHint": true
  }
}
```

### Reglas del esquema

| Regla | Exigencia | Motivo |
|---|---|---|
| Todo parámetro **opcional acepta `null`** | **DEBE** | Sirven las dos formas: `"type": ["string","null"]` o `anyOf` con una rama `{"type":"null"}` (es lo que genera zod con `.nullable()`). Ver el aviso de abajo: es el requisito que más veces se pasa por alto y el que más rompe. |
| `required` con **solo lo imprescindible** | **DEBE** | Un `required` largo hace que el modelo invente valores para poder llamar a la herramienta. Deje fuera todo lo que su sistema pueda resolver por defecto. |
| `additionalProperties: false` | **DEBE** | Un esquema abierto invita al modelo a inventar campos. Es seguro: los metadatos del Gateway viajan en `_meta`, no en `arguments`. |
| Valores cerrados como `enum` | **DEBE** | Lo que no esté enumerado, el modelo lo adivina en lenguaje natural. |
| `maxLength` en todo texto libre | **DEBE** | Protege su backend y acota el coste del turno. |
| Sin identificadores internos como entrada obligatoria | **DEBE** | El modelo no puede construir un identificador suyo. Si hace falta, exponga primero una herramienta que lo resuelva. |
| Una operación por herramienta | **DEBE** | Un parámetro `accion` que multiplexa varias operaciones degrada la elección del modelo y no pasa la certificación. |
| Sin anidamiento profundo ni `oneOf`/`anyOf` en la raíz | **DEBERÍA** | Objetos planos de una capa se aciertan mucho más. |

> **Por qué los opcionales DEBEN aceptar `null`.** El proveedor del modelo valida el llamado contra
> su esquema **antes** de entregárnoslo. Si un parámetro opcional declara un tipo estricto y el
> modelo envía `null` (cosa que hace a menudo en vez de omitir el campo), la API responde **400** y
> **se cae la generación completa**: no es que falle esa herramienta, es que el agente se queda sin
> respuesta en medio de la conversación. Nos ocurrió en producción con nuestras propias
> herramientas; desde entonces todos los opcionales de nuestro catálogo admiten `null`. Los suyos
> también deben admitirlo.

### Clasificación de la herramienta: `_meta` en su definición

Cada herramienta **DEBE** declarar en el `_meta` de su propia definición a qué escenario del agente
pertenece y con qué verbo se le describe al modelo. No se manda en un archivo aparte ni se configura
en un panel: viaja con el catálogo, así que cuando usted añade una herramienta, ya viene clasificada.

| Clave en el `_meta` de la herramienta | Exigencia | Valor |
|---|---|---|
| `com.asixto/capability` | **DEBE** | Una de las **claves canónicas de escenario** de la [página 04](./04-capabilities.md). Determina en qué conversaciones se expone. |
| `com.asixto/verb` | **DEBE** | El verbo de negocio en infinitivo con el que el agente la nombra ante el cliente: `"agendar una cita"`, `"consultar el estado de un caso"`. |
| `com.asixto/capabilities` | PUEDE | Lista, si la misma herramienta sirve a varios escenarios (`["pqr","soporte"]`). Sustituye a la clave singular. |

```json
{
  "name": "abrir_caso_soporte",
  "description": "…",
  "inputSchema": { "…": "…" },
  "annotations": { "readOnlyHint": false, "destructiveHint": false, "idempotentHint": true },
  "_meta": {
    "com.asixto/capabilities": ["pqr", "soporte", "garantia"],
    "com.asixto/verb": "abrir un caso de soporte"
  }
}
```

> **Por qué es obligatorio y no un detalle.** El agente no expone todo su catálogo en cada
> conversación: filtra por escenario, y el texto que le dice al modelo qué puede hacer se construye
> con esos verbos. Una herramienta **sin `capability` no se expone nunca**, y una **sin `verb`** queda
> disponible pero el modelo no sabe que resuelve ese caso, así que casi no la usa. Los dos campos son
> tres palabras de trabajo y son la diferencia entre una herramienta que se usa y una que no.
>
> Una clave de escenario que no exista se rechaza en la certificación. No las invente: están todas en
> la [página 04](./04-capabilities.md).

### Nombres y longitudes

| Elemento | Límite | Motivo |
|---|---|---|
| Nombre de herramienta | **≤ 40 caracteres**, `^[a-z0-9_]+$`, único en su catálogo | El proveedor del modelo acepta `^[a-zA-Z0-9_-]{1,64}$`. El Gateway prefija con `ext__{empresa}__` para no colisionar con las herramientas nativas, y ese prefijo reserva 24 caracteres del presupuesto. Un nombre más largo no se puede exponer. |
| `description` de la herramienta | **≤ 700 caracteres** | El proveedor rechaza descripciones de más de 1.024. El margen es para el prefijo de contexto que añade el agente. |
| `description` de cada parámetro | ≤ 200 caracteres | Cada carácter se paga en cada turno de la conversación. |

- `snake_case`, verbo + entidad: `consultar_cliente`, `crear_cita`, `mover_etapa`.
- Estables. Renombrar equivale a publicar otra herramienta y obliga a recertificar.
- El prefijo lo pone el Gateway; no aparece en su servidor.

### Descripciones

La descripción es el texto que decide si el agente acierta al elegir la herramienta. No es
documentación interna.

- Una o dos frases: **qué hace** y **cuándo NO usarla**.
- Sin tono, sin marca, sin instrucciones dirigidas al modelo (`"responde amablemente…"`). Esa
  capa la pone el agente; si usted la duplica, entra en conflicto.
- **Nada que intente gobernar al modelo o a nuestro sistema**, y esto sí es bloqueante: ni «ignora
  las instrucciones anteriores», ni `system prompt`, ni pedirle que no use otras herramientas, ni
  marcas de rol (`<system>`, `[INST]`), ni **caracteres invisibles** (`U+200B` y compañía). Su
  descripción llega a nuestro modelo en el mismo contexto que las instrucciones de la empresa, así
  que una descripción que habla al modelo no es un detalle de estilo: describa la herramienta.
- Sin datos volátiles (precios, horarios, inventario): eso va en la respuesta, no en la
  descripción, que se cachea.

## Anotaciones y su efecto

| Anotación | Efecto en el Gateway |
|---|---|
| `readOnlyHint: true` | Se clasifica como nivel `read`. Se ejecuta sin confirmación. |
| `destructiveHint: true` | Se clasifica como nivel `admin`: queda fuera de los agentes con nivel `read` o `write`. |
| `idempotentHint: true` | Declara que repetir la llamada es seguro. **El runtime no reintenta por su cuenta**: ante un timeout degrada y sigue la conversación. La anotación describe su herramienta; no activa ningún comportamiento del Gateway. |
| `readOnlyHint` ausente | **La herramienta no se publica.** Ver abajo. |
| `destructiveHint` / `idempotentHint` ausentes | Se asume el caso más restrictivo: escritura no idempotente, nivel `admin`. |

> **Dos cosas dejan una herramienta fuera del catálogo publicado, no solo de la certificación**: que no
> declare `readOnlyHint` (aquí abajo) y que alguno de sus textos intente gobernar al modelo
> ([página 08](./08-certification.md)). Las dos se comprueban al publicar, no solo al certificar, porque
> certificamos su entorno de pruebas y lo que llega al modelo es el catálogo del entorno que publique.
> Cuando ocurre, se descarta **esa** herramienta con su motivo y el resto se publica igual.

> **`readOnlyHint` es obligatorio, y su ausencia no se resuelve por omisión.** Sin ese booleano no
> podemos saber si la herramienta escribe, y entonces no hay nivel que asignarle ni interruptor por
> acción que darle: se queda fuera del catálogo publicado, con el motivo en el informe. No es una
> excepción de la certificación —es la condición para persistirla— así que su verificador la trata
> como bloqueante y no como aviso.

Las anotaciones son **pistas**, no autoridad: el estándar las define como no verificables por el
consumidor. El Gateway las usa para clasificar el nivel; la decisión de exponer o no es del control
de acceso de Asixto y de la configuración del tenant. Declarar de solo lectura una herramienta que
escribe es un incumplimiento de contrato y bloquea la certificación.

> **No delegue la confirmación en el agente.** Que el agente pida confirmación antes de una acción
> sensible es una instrucción al modelo, y las instrucciones al modelo no son deterministas: en
> nuestras mediciones se cumplen alrededor del 92% de las veces.
>
> Para cualquier operación irreversible (cobrar, cancelar, borrar) la guarda que cuenta es la de
> **su** sistema: exija un campo de confirmación explícito en el esquema, valide estado y propiedad,
> y rechace lo que no cuadre. El porcentaje restante, cuando hay dinero o una cita de por medio, se
> convierte en un incidente.

## Metadatos que envía el Gateway

Van en el campo **`_meta` de los `params` de `tools/call`**, no en `arguments`. Esto es deliberado:
`_meta` es el punto de extensión que define el propio estándar, así que su `inputSchema` puede
seguir cerrado sin rechazar nada. El SDK se los entrega sin que usted declare nada.

| Clave en `_meta` | Tipo | Uso |
|---|---|---|
| `com.asixto/idempotencyKey` | string | Clave determinista por operación lógica, derivada de la conversación, la herramienta y la firma de los argumentos. **En toda escritura**: persistirla y no repetir el efecto. |
| `com.asixto/conversationId` | string | Ámbito de la conversación en curso. Dos conversaciones distintas nunca comparten clave de idempotencia. |
| `com.asixto/requestId` | string | Correlación de traza. Regístrelo: es la referencia para cualquier incidente. |
| `com.asixto/channel` | `"chat"` o `"voice"` | Canal. **Úselo**: en `voice` la respuesta se lee en voz alta y aplican las restricciones de la [página 07](./07-limits.md). |
| `com.asixto/companyId` | string | Empresa del tenant. Relevante si opera varias marcas o unidades de negocio detrás del mismo servidor. |
| `com.asixto/priceListId` | string o `null` | Lista de precios vigente para esa conversación. **Si su catálogo tiene varias, el precio debe salir de esta**, no del precio por defecto. |
| `com.asixto/currency` | string | Moneda del tenant en ISO 4217 (`COP`, `USD`…). Los importes que devuelva deben ser de esta moneda. |
| `com.asixto/timezone` | string | Zona horaria del tenant (`America/Bogota`). Toda fecha que devuelva debe llevar offset coherente con ella. |
| `com.asixto/locale` | string | Idioma de atención del tenant en BCP 47 (`es-CO`, `pt-BR`). Los textos legibles que devuelva —nombres, estados, `user_message`— **deben** estar en ese idioma. Si su servidor solo atiende uno, decláreselo en la incorporación. |

```json
{
  "method": "tools/call",
  "params": {
    "name": "abrir_caso_soporte",
    "arguments": { "documento": "CC1032…", "tipo": "falla", "descripcion": "…" },
    "_meta": {
      "com.asixto/idempotencyKey": "9f2a7c…",
      "com.asixto/conversationId": "cnv_77b…",
      "com.asixto/requestId": "req_c41…",
      "com.asixto/channel": "voice",
      "com.asixto/companyId": "cmp_31a…",
      "com.asixto/priceListId": "pl_mayorista",
      "com.asixto/currency": "COP",
      "com.asixto/timezone": "America/Bogota",
      "com.asixto/locale": "es-CO"
    }
  }
}
```

> **Lo que el Gateway NO envía:** una identidad autenticada del usuario final. La conversación
> ocurre en WhatsApp, en un chat web o por teléfono, y ahí no hay sesión. Quien dice ser el cliente
> lo afirma en la conversación. Consecuencia directa para usted: la validación de propiedad
> (garantía 2 de la [página 05](./05-guarantees.md)) se hace contra el identificador que su propia
> herramienta de consulta ya resolvió en esa conversación, no contra un token de usuario.

## Forma de la respuesta

- `content`: texto breve para el modelo. Es lo que puede terminar leído en voz alta.
- `structuredContent`: los datos, conformes a su `outputSchema`.

Devuelva **datos ya resueltos**: el nombre en vez del identificador, el precio final en vez de
sus componentes, la fecha completa en vez de un código interno. Una respuesta pensada para una
tabla en pantalla no sirve en una llamada telefónica.

**Tamaño: 3.000 caracteres.** No es un consejo: el runtime sanea y **recorta a 3.000 caracteres**
todo resultado antes de inyectarlo al modelo, dejando una marca de truncado. No es un error, así que
su servidor no se entera: simplemente el agente responde con datos incompletos. Si su respuesta
natural es más grande, pagine ([página 07](./07-limits.md)).

**Una búsqueda sin resultados es un éxito, no un error.** Devuelva la lista vacía y el total en 0.
Si la reporta como error, el agente entra en la rama de fallo técnico y reintenta o cambia de paso
en vez de decirle al cliente, con naturalidad, que no encontró nada.

Prohibido en la respuesta: identificadores internos, datos de terceros, campos administrativos,
trazas, tokens. El resultado entra al contexto del modelo y queda en la traza de la conversación.

**Un fallo de negocio usa la misma respuesta, marcada como error.** No es un error de transporte: se
devuelve con `isError: true` y el objeto de error en `structuredContent`. La forma exacta y los seis
valores admitidos están en la [página 06](./06-errors.md).

```json
{
  "isError": true,
  "content": [{ "type": "text", "text": "{\"error\":{…}}" }],
  "structuredContent": { "error": { "error_type": "NOT_FOUND", "user_message": "No encontré una cita con esos datos." } }
}
```

El campo `resultType` que exige el estándar **lo pone el SDK**, no usted.

**Media (imágenes, PDF, audio): no en la v1.** El envío de archivos por el canal exige un
identificador de media resuelto contra el backend de Asixto; una URL o un adjunto que devuelva su
herramienta **no se le entrega al cliente final**, y en llamada se elimina del texto. Si su caso de
uso lo necesita, escálelo antes de construirlo.

---

<!-- fuente: https://gateway.asixto.com/docs/03-authentication.md -->

# 03 · Autenticación

Su servidor MCP es un endpoint expuesto a internet que puede leer y escribir en sus sistemas.
La autorización es opcional en el estándar; **en este contrato no lo es**.

Hay dos niveles admitidos. Ambos son servidor a servidor: no hay usuario final, ni pantallas
de consentimiento, ni OAuth de terceros.

## Nivel 1 · OAuth 2.1 (recomendado)

Su servidor actúa como **servidor de recursos** OAuth 2.1. Asixto obtiene un token por
credenciales de cliente y lo presenta en cada petición.

Requisitos:

| # | Requisito | Exigencia |
|---|---|---|
| 1 | Publicar **metadatos de recurso protegido** (RFC 9728) en `/.well-known/oauth-protected-resource` | **DEBE** en el nivel 1 |
| 2 | Responder **401** con `WWW-Authenticate` indicando el `scope` requerido cuando falta o es inválida la credencial | **DEBE** |
| 3 | **Validar la audiencia** del token (RFC 8707): rechazar todo token que no fue emitido para su servidor | **DEBE** |
| 4 | Exponer descubrimiento del servidor de autorización (RFC 8414 u OpenID Connect Discovery) | **DEBE** |
| 5 | Soportar documentos de metadatos de cliente (Client ID Metadata Documents) | DEBERÍA |

El registro dinámico de cliente (RFC 7591) quedó **obsoleto** en la revisión 2026-07-28 y se
mantiene solo por compatibilidad. No lo requiera.

**Cómo obtiene Asixto sus credenciales de cliente.** Sin registro dinámico queda un solo camino, y
es manual a propósito: usted crea en su servidor de autorización unas credenciales de cliente para
Asixto —flujo de **credenciales de cliente**, con el scope `mcp:tools`— y registra el `client_id` y
el secreto junto con la URL de su servidor, en la aplicación de Asixto
([página 08](./08-certification.md)). Las rota cuando quiera desde el mismo sitio; la rotación
admite dos credenciales activas a la vez, igual que en el nivel 2. `mcp:tools` es el scope del
contrato: es el que su servidor **debe** exigir y el que Asixto solicita.

### Con el SDK de TypeScript

El paquete de Express trae las piezas hechas:

```ts
import {
  createMcpExpressApp,
  requireBearerAuth,
  mcpAuthMetadataRouter,
  getOAuthProtectedResourceMetadataUrl,
} from '@modelcontextprotocol/express';

const app = createMcpExpressApp();

// Publica los metadatos de recurso protegido (RFC 9728)
app.use(mcpAuthMetadataRouter({ /* su servidor de autorización */ }));

// Protege el endpoint MCP: valida Authorization: Bearer … con su verificador.
// `requiredScopes` es lo que hace que un token sin el scope del contrato reciba
// 403 `insufficient_scope` en vez de pasar.
app.use(
  '/mcp',
  requireBearerAuth({
    verifier: miVerificadorDeTokens,
    requiredScopes: ['mcp:tools'],
    resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(new URL('https://mcp.suempresa.com')),
  })
);
```

`getOAuthProtectedResourceMetadataUrl(serverUrl)` construye la URL que debe anunciar en la
cabecera `WWW-Authenticate`.

### Respuesta esperada sin credencial

```http
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.suempresa.com/.well-known/oauth-protected-resource", scope="mcp:tools"
```

## Nivel 2 · Token rotable (mínimo aceptable)

Un token opaco emitido por usted, presentado como `Authorization: Bearer …`.

| Requisito | Exigencia |
|---|---|
| Longitud ≥ 32 bytes de entropía | **DEBE** |
| Caducidad declarada | **DEBE** |
| Dos credenciales activas simultáneas durante la rotación | **DEBE** |
| Rotación sin intervención de Asixto | **DEBE** |
| 401 (no 403 ni 500) cuando falta o es inválido | **DEBE** |

Se admite para incorporación rápida y entornos de prueba. El registro la guardará cifrada y nunca
la escribe en trazas.

## Defensa en profundidad

Además del token:

- **TLS obligatorio** con certificado válido. Sin excepciones, tampoco en pruebas.
- **Lista blanca de dominio**: el Gateway llamará únicamente a la URL registrada del tenant.
- **Restricción por origen** (opcional): puede limitar por el rango de direcciones del Gateway.
- **Validación de cabecera Host** si su servidor también escucha en local: el SDK trae
  `hostHeaderValidation` para protección ante DNS rebinding.

## Reglas duras de aislamiento

1. **Asixto nunca reenvía a su servidor un token de la plataforma Asixto.** Su servidor solo
   recibe la credencial que usted mismo emitió. Esto evita que un servidor externo pueda actuar
   en nombre del tenant contra la plataforma.
2. **Un servidor registrado queda ligado a un único tenant.** No debe responder por otra
   empresa, y el Gateway no comparte su catálogo con otros tenants.
3. **Sin secretos en las respuestas ni en los mensajes de error.** El texto de error puede
   terminar leído en voz alta en una llamada.

## Lista de verificación

- [ ] Petición sin `Authorization` → 401 con `WWW-Authenticate`.
- [ ] Token de otra audiencia → 401.
- [ ] Token caducado → 401 (no 500).
- [ ] Metadatos de recurso protegido accesibles y coherentes con la URL del servidor.
- [ ] Rotación probada con las dos credenciales activas.
- [ ] Ningún token en logs de su lado.

---

<!-- fuente: https://gateway.asixto.com/docs/04-capabilities.md -->

# 04 · Catálogo requerido

Qué herramientas debe exponer según lo que quiera que el agente resuelva.

## Contexto: el catálogo de referencia

El agente de Asixto opera hoy en producción sobre un catálogo cerrado de **34 herramientas
tipadas** que le entrega la plataforma Asixto. En una integración con sistemas propios ese catálogo
se reparte en dos:

| Lado | Herramientas | Reparto | Qué cubre |
|---|---|---|---|
| **Su empresa** | **25** | 13 de lectura, 11 de escritura, 1 administrativa | Empresa y sedes, catálogo, clientes, casos, oportunidades, agenda |
| **Asixto** | **9** | 5 de contexto, 2 de escritura sobre la conversación, 2 comandos de llamada | Escalado a un asesor, historial de la conversación, memoria del agente, procedimiento del escenario, nota y estado de la conversación, búsqueda en internet, fin y transferencia de llamada |

De las 25 de su lado, **12 mutan** su sistema: las 11 de escritura más la administrativa.

Usted no construye las 9 de Asixto. Dos advertencias sobre ellas, porque afectan a lo que usted sí
construye: el **escalado** necesita destinatario (ver el final de esta página) y en **llamada
telefónica** varias no se ejecutan (ver la sección de divergencia por canal).

## Núcleo obligatorio

Sin estas tres el agente no puede operar: no sabe de qué empresa habla ni con quién.

| Herramienta | Nivel | Debe devolver |
|---|---|---|
| Información de la empresa | `read` | Nombre comercial, actividad, horarios de atención, canales, políticas y datos de contacto públicos. Es la base del *grounding*: sin esto el agente responde genérico o inventa. |
| Consulta de cliente | `read` | Identificación por documento, teléfono o correo. Datos de contacto y estado. Determina si atiende a un cliente conocido. |
| Sedes más cercanas | `read` | **Obligatoria solo con más de una sede.** Dirección, horario y distancia a una referencia dada. Con una sola sede, esa información va en «información de la empresa» y el núcleo queda en dos herramientas. |

## Familias por dominio

| Dominio | Herram. | Operaciones esperadas | Requisito propio |
|---|---|---|---|
| **Catálogo** | 2 | Búsqueda con filtros y paginación · detalle de un ítem | Precio vigente por lista de precios, existencia y unidad. El detalle trae las reglas de venta que el agente no puede inventar. |
| **Clientes** | 1 | Actualización de datos de contacto | Solo campos que el titular puede cambiar por sí mismo. Valida propiedad del registro. |
| **Casos** | 2 | Apertura con tipo y descripción · consulta de estado | Debe devolver un **número de radicado** en la misma respuesta: es lo que el agente le dice al usuario. Idempotente por conversación. |
| **Oportunidades** | 9 | Ver embudo · ver oportunidad · listar cotizaciones · crear oportunidad · mover etapa · crear cotización · enviar cotización · aceptar o rechazar cotización · cerrar oportunidad | Guardas de estado terminal. Montos validados contra su propio catálogo. |
| **Agenda** | 8 | Configuración y duración · franjas disponibles · listar citas · ver una cita · crear · modificar · confirmar o rechazar · cancelar | La disponibilidad manda: rechace una franja ocupada aunque venga solicitada. Modificar y cancelar exigen propiedad de la cita. La cancelación va con `destructiveHint: true`, así que **solo esa herramienta** requiere nivel `admin`; las otras siete funcionan con `write`. |

## Campos mínimos por dominio

El protocolo es autodescriptivo: usted nombra los campos como quiera y el agente lee su esquema. Pero
el agente **promete cosas concretas al cliente final** (un precio, un radicado, una hora), así que hay
un mínimo por dominio. Sin ese mínimo, dos integraciones igualmente correctas producen comportamientos distintos ante el
mismo cliente.

Los nombres de campo son suyos; lo que no es negociable es que **el dato exista y venga resuelto**.

> **Sobre escritura y borrado.** El contrato es de lectura **y escritura**: de las 25 herramientas
> del catálogo de referencia, **12 mutan** (11 de escritura y 1 administrativa). Lo que el agente
> **no** hace es borrado duro: no existe «eliminar cliente», «eliminar producto» ni «eliminar
> oportunidad». La única operación de tipo borrado es **cancelar una cita**, y va con
> `destructiveHint: true`. Si su operación necesita que el agente elimine registros, escálelo antes
> de construirlo: el borrado duro está fuera del alcance del contrato.

| Dominio | Su respuesta DEBE traer | Motivo |
|---|---|---|
| **Empresa** | Nombre comercial, actividad, horarios de atención, medios de contacto públicos y políticas que el agente pueda citar | Es el *grounding*: sin esto el agente responde genérico o inventa |
| **Sedes** | Por sede: nombre, dirección, horario y, si hay referencia de ubicación, distancia | Sin dirección y horario, «la sede más cercana» no significa nada |
| **Catálogo · búsqueda** | Por ítem: identificador **opaco y estable**, nombre, **precio final** en la moneda del `_meta`, disponibilidad y unidad. Además `total`, `limit`, `offset` | El agente cotiza de palabra: si el precio no viene resuelto, no puede decirlo |
| **Catálogo · detalle** | Lo anterior más descripción corta, variantes y **reglas de venta** (mínimos, requisitos, restricciones) | Las reglas que no vengan, el modelo las improvisa |
| **Clientes · consulta** | Si existe o no, nombre, estado y medios de contacto | Determina si atiende a un cliente conocido y si puede continuar |
| **Clientes · actualización** | Qué campos quedaron efectivamente actualizados | El agente confirma al cliente solo lo que usted confirme |
| **Casos · apertura** | **Número de radicado** y estado, en la misma respuesta | Es lo que el agente le dice al cliente. Sin radicado no hay cierre de la conversación |
| **Casos · consulta** | Estado, fecha de última actualización y un resumen legible | «Su caso sigue abierto» sin fecha no sirve de respuesta |
| **Agenda · configuración** | Duración de la cita, anticipación mínima y horarios de atención | Sin esto el agente ofrece horas imposibles |
| **Agenda · franjas** | Cada franja como **instante ISO con offset** y, además, texto pronunciable | En llamada se lee en voz alta; en chat se necesita el dato exacto |
| **Agenda · cita** | Identificador, instante, estado y titular | El estado es lo que permite rechazar operaciones sobre citas canceladas |
| **Embudo · oportunidad** | Identificador, etapa actual, **etapas válidas siguientes**, monto con moneda y estado | Sin las etapas válidas, el agente propone transiciones que usted rechaza |
| **Embudo · cotización** | Identificador, estado, monto con moneda y vigencia | La vigencia evita que el agente ofrezca un precio caducado |

> **Lo que NO debe traer, en ningún dominio:** claves primarias internas, identificadores de sistema,
> campos administrativos, datos de terceros, URLs de imagen (la media es inerte en la v1) ni nada que
> no pueda decirse en voz alta.

## Claves canónicas de escenario

Son los valores admitidos en `com.asixto/capability` ([página 02](./02-tool-contract.md)). Cualquier
otro valor se rechaza en la certificación.

```
actualizacion_datos      cambio_plan          garantia                notificaciones     reclamo_calidad
agenda                   cancelacion          informacion_empresa     pqr                renovacion
busqueda_web             consulta_estado      informacion_productos   reactivacion       reporte_fraude
encuestas_satisfaccion   emergencia           informacion_servicios   seguimiento_postventa
facturacion              felicitacion         lead                    solicitud_documentos
soporte                  ventas
```

Si su operación necesita un escenario que no está en la lista, escálelo: se evalúa como capacidad
nueva del agente, no se improvisa con una clave inventada.

### Cómo elegir la clave, y por qué importa más de lo que parece

La clave que anote **decide en qué conversaciones se ofrece su herramienta al agente**. Si anota una
clave válida pero que no corresponde a las conversaciones donde su herramienta hace falta, la
herramienta queda registrada, aprueba la certificación y **el agente nunca la usa**. No recibirá ningún
error: simplemente no se ofrece. Es el fallo más silencioso del contrato, así que vale la pena elegir
con calma.

La pregunta que resuelve el 90% de los casos: **¿de qué está hablando el cliente final cuando su
herramienta hace falta?** No qué hace la herramienta por dentro, ni cómo se llama en su sistema.

| Si el cliente final está hablando de… | Anote |
|---|---|
| Un pago, una deuda, una cuota, un estado de cuenta, una factura | `facturacion` |
| El avance de un caso o ticket que ya existe, por su radicado | `consulta_estado` |
| Reservar, mover o cancelar una cita o una visita | `agenda` |
| Sus datos de contacto: correo, teléfono, dirección, documento | `actualizacion_datos` |
| Qué vende usted, precios, disponibilidad | `informacion_productos` o `informacion_servicios` |
| Comprar, contratar, pedir cotización | `ventas` |
| Interés inicial de alguien que aún no es cliente | `lead` |
| Una queja, un reclamo formal o una petición | `pqr` |
| Algo que no funciona y hay que resolver técnicamente | `soporte` |
| Un producto defectuoso, una devolución | `garantia` |
| Darse de baja, cancelar el servicio | `cancelacion` |
| Volver después de haberse ido, reactivar una cuenta inactiva | `reactivacion` |
| Renovar lo que ya tiene · cambiar de plan | `renovacion` · `cambio_plan` |
| Enviar o recibir un documento | `solicitud_documentos` |
| Algo urgente que necesita una persona ya | `emergencia` |

**Una herramienta puede declarar varias claves** con `com.asixto/capabilities` (plural) si de verdad
hace falta en conversaciones distintas. Ojo: **solo se honra la primera** para resolver a qué capacidad
pertenece su interruptor, así que ponga primero la principal.

**Dos avisos que evitan los errores más frecuentes:**

- **No anote todas sus herramientas con la misma clave.** Si las cinco caen en `facturacion`, el agente
  no tendrá ninguna en el resto de las conversaciones. La certificación se lo advierte.
- **Una herramienta que MUTA nace apagada.** Además de la clave, alguien de su cuenta tiene que
  **encender su interruptor** en el panel: es una escritura en su sistema y la autoriza usted, no
  nosotros. Si la herramienta se ofrece en lectura y no en escritura, revise eso antes que la clave.

## Escenario → herramientas requeridas

El agente organiza la atención en **25 escenarios de negocio**. Cada uno declara qué necesita:
**si su sistema no lo expone, ese escenario no se enciende**. Así se acota el alcance sin
ambigüedad.

| Escenario | Herramientas que debe exponer |
|---|---|
| Información de la empresa · consultas generales · facturación · felicitaciones · encuestas de satisfacción · seguimiento posventa · solicitud de documentos | Solo el núcleo obligatorio. Son escenarios conversacionales: los diferencian las instrucciones del agente, no herramientas extra. |
| Información de productos · información de servicios · ventas · cambio de plan | Búsqueda en catálogo + detalle de ítem |
| Reactivación · renovación | Búsqueda en catálogo |
| Actualización de datos · gestión de notificaciones | Actualización de cliente |
| Consulta de estado de un caso | Consulta de estado de caso |
| PQR · soporte técnico | Apertura de caso + consulta de estado + búsqueda en catálogo |
| Garantía · reclamo de calidad · cancelación | Apertura de caso + detalle de ítem + búsqueda en catálogo |
| Emergencia · reporte de fraude | Apertura de caso |
| Agenda de citas | Las 8 operaciones de agenda |
| Captación y calificación de oportunidades | Catálogo + las 9 operaciones del embudo |
| Búsqueda en internet | Ninguna: la aporta Asixto |

## Secuencia recomendada

| Fase | Herramientas | Qué habilita | Riesgo |
|---|---|---|---|
| **1** | 5 (núcleo + catálogo) | Atender, informar, cotizar de palabra, escalar a humano | Nulo: todas son de lectura, ninguna escribe en sus sistemas |
| **2** | +3 (casos + actualización de cliente) | Agente resolutivo: abre radicados y corrige datos | Entran idempotencia y validación de propiedad ([página 05](./05-guarantees.md)) |
| **3** | +17 (agenda + embudo) | Agenda citas y gestiona el pipeline comercial | Los dos dominios con estado y transiciones. Aborde uno por vez, con las fases 1 y 2 ya en producción |

Cada fase se certifica y se activa por separado. Agregar una fase no obliga a recertificar las
anteriores, salvo que cambie una herramienta ya certificada.

## Control de exposición

Sus herramientas entran al catálogo del agente sujetas al filtro de la plataforma:

1. **Capacidad activa**: el escenario está habilitado para el tenant. Si no, la herramienta no
   existe para el modelo.
2. **Nivel**: `read` ⊆ `write` ⊆ `admin`, derivado de sus anotaciones.

Hay una tercera capa, el **interruptor por acción sensible**, que exige una clave declarada en el
catálogo de capacidades. Para una herramienta suya, esa clave **se declara a petición** durante la
incorporación; sin ella, la protección de esa herramienta son la capacidad y el nivel. Si una
operación concreta necesita su propio interruptor, pídalo: no lo asuma incluido.

Consecuencia para su diseño: **la guarda de último recurso de una operación irreversible vive en su
sistema**, no en nuestra configuración. Ver el aviso sobre confirmación en la
[página 02](./02-tool-contract.md).

## Las instrucciones del tenant también invocan sus herramientas

El agente no solo reacciona a lo que pide el cliente final. El tenant escribe **instrucciones** en
Asixto —cómo atender, qué priorizar, qué hacer en cada situación— y esas instrucciones llegan al
agente **junto con el catálogo de herramientas**. Una instrucción puede señalar una herramienta
concreta y exigir que se llame en ese turno.

Dicho de otro modo: **su herramienta puede ejecutarse porque una instrucción lo ordena, no porque el
usuario lo haya pedido**. Eso tiene tres consecuencias directas sobre su diseño:

| Consecuencia | Qué implica en su servidor |
|---|---|
| El `com.asixto/verb` no es decorativo | Es el nombre con el que la herramienta aparece en las instrucciones y en el razonamiento del agente. Un verbo vago («gestionar datos») hace que se invoque cuando no toca, o que no se invoque cuando sí. Escríbalo como una acción concreta: «mover la oportunidad a la etapa indicada». |
| La llamada puede no venir precedida de una pregunta | No asuma que antes hubo una consulta que ya validó al cliente. Cada herramienta valida por sí misma: propiedad del registro, estado válido y permisos, siempre ([página 05](./05-guarantees.md)). |
| Puede llegar más de una vez | Una instrucción se puede cumplir en varios turnos de la misma conversación. La clave de idempotencia es lo que impide el efecto doble: persístala en **toda** escritura. |

Y una consecuencia sobre el alcance: una instrucción solo puede pedir lo que su catálogo expone. Si
el tenant quiere que el agente mueva una oportunidad de etapa, la herramienta de cambio de etapa
tiene que existir, estar clasificada en un escenario activo y admitir el nivel que esa operación
necesita. Lo que no está expuesto no se puede instruir.

## Divergencia por canal: qué NO ocurre en una llamada

El agente atiende chat y teléfono con el mismo catálogo, pero **en llamada hay familias enteras que
no se ejecutan**, por diseño: en tiempo real, sin pantalla y sin poder confirmar por escrito, esas
acciones exigen validación humana.

| En llamada telefónica | Qué pasa |
|---|---|
| Apertura de caso | **No se ejecuta.** Un PQR pedido por teléfono no abre radicado, aunque usted exponga la herramienta. El agente toma el caso y ofrece transferir o registrar el contacto. |
| Nota interna en la conversación | No se ejecuta. |
| Búsqueda en internet | No se ejecuta. |
| Escalado a un asesor | No existe como herramienta; su sustituto es la **transferencia de la llamada**. |
| Lectura de catálogo, clientes, agenda y embudo | Se ejecutan con normalidad. |

Si su operación necesita que una llamada abra un caso en su sistema, hay dos caminos: exponer la
apertura de caso como parte de otra familia que sí corra en voz, o aceptar que el registro ocurra
después, con la transcripción. Conviene decidirlo antes de construir las herramientas de esa familia.

Además, en voz aplican las restricciones de forma de la [página 07](./07-limits.md): máximo 3
elementos por lista, nada de URLs ni correos, y fechas en ISO **y** en texto pronunciable.

## Escalado a un humano: a quién

El escalado lo aporta Asixto, con un matiz que afecta a su operación: la herramienta de escalado **reasigna la conversación dentro de la plataforma Asixto** y
necesita un asesor dado de alta ahí. Tres escenarios:

| Su situación | Qué hacer |
|---|---|
| Sus asesores atenderán las conversaciones en Asixto | Nada. Se dan de alta como usuarios y el escalado funciona. |
| Sus asesores viven en su propio CRM | **Exponga una herramienta de handoff** (crear tarea, asignar conversación, notificar) y decláremela como la vía de escalado de su tenant. |
| Atención telefónica | Facilite un **número de destino para transferencia**. Sin él, en llamada el agente solo puede ofrecer que alguien devuelva la llamada. |

Si no resuelve esto, el escalado queda sin destinatario: el agente dirá que un asesor lo contactará
y nadie lo contactará. La conversación termina con una promesa que nadie va a atender.

---

<!-- fuente: https://gateway.asixto.com/docs/05-guarantees.md -->

# 05 · Garantías obligatorias

Seis garantías que hoy aporta la plataforma Asixto y que su sistema debe aportar en su lugar.
No son buenas prácticas genéricas: cada una corresponde a una forma concreta en que un agente
conversacional rompe datos reales. Todas son **comprobaciones bloqueantes** de la certificación.

Aplican a las herramientas que escriben: las que usted declara con un `com.asixto/verb` de
escritura ([página 02](./02-tool-contract.md)). Las de **solo lectura** quedan exentas de la 1, la 3
y la 4, pero **deben cumplir la 2, la 5 y la 6**: una lectura que no valida la propiedad del
registro entrega datos de otra persona con solo probar un identificador, y eso es la fuga más
barata que existe en una integración conversacional.

---

## 1 · Idempotencia en toda escritura

El Gateway envía `com.asixto/idempotencyKey` en el **`_meta`** de la llamada (no en los
argumentos) con una clave determinista por **operación lógica**: se deriva de la empresa, la
conversación, el nombre de la herramienta y la firma de los argumentos, así que un doble intento del
modelo la repite idéntica y dos conversaciones distintas nunca comparten clave.

Su servidor debe persistirla y, si la vuelve a recibir, **devolver el mismo resultado sin volver a
ejecutar**.

> **El orden importa, y es contraintuitivo: la consulta de la clave va ANTES de las guardas de
> propiedad y de estado terminal.** Si va después, pasa esto:
>
> ```
> 1er intento → cancela la cita → éxito
> 2º  intento → la guarda ve «ya cancelada» → VALIDATION
> ```
>
> …y el agente le dice al cliente que su cancelación falló, cuando en realidad funcionó. El estado
> cambió por **su propia primera ejecución**, así que no es una transición inválida: es el mismo
> trabajo pedido dos veces. Guarde **solo los éxitos**: un fallo transitorio debe poder reintentarse.

```python
async def cancelar_cita(cita_id: str, documento: str, *, meta: dict) -> dict:
    key = meta["com.asixto/idempotencyKey"]

    # 0 · PRIMERO la clave, antes de cualquier guarda. `add` es SET NX: reserva ATÓMICA.
    #     Un `get` y luego un `set` no basta: dos llamadas con la misma clave en vuelo
    #     fallan el `get` a la vez y ejecutan las dos. Ver «clave en vuelo» en la página 10.
    if not await store.add(key, EN_CURSO, ttl=60):
        previo = await store.get(key)
        if previo is None or previo is EN_CURSO:
            return error("SYSTEM", "Ya estoy procesando esa solicitud.")
        return previo

    exito = False
    try:
        cita = await mi_backend.buscar_cita(cita_id)
        if cita is None:
            return error("NOT_FOUND", "No encontré esa cita.")

        # 1 · Propiedad. Esta guarda también aplica a las herramientas de solo lectura.
        if cita.titular != documento:
            return error("PERMISSION", "Esa cita no está a nombre de quien me habla.")

        # 2 · Estado terminal.
        if cita.estado != "agendada":
            return error("VALIDATION", f"Esa cita ya está {cita.estado}.")

        resultado = await mi_backend.cancelar(cita_id)
        await store.set(key, resultado, ttl=7 * 24 * 3600)   # solo los éxitos
        exito = True
        return resultado
    finally:
        if not exito:
            await store.delete(key)   # error o fallo transitorio: debe poder reintentarse
```

Requisitos del almacén: **TTL de al menos 7 días** y escritura **atómica** (`SET NX` o índice
único), no `get` seguido de `set` sin transacción.

Dos precisiones que evitan un error caro:

- **La clave incluye la conversación**, así que dos clientes finales distintos que pidan lo mismo
  con los mismos datos **no** comparten clave. Si su implementación deduplica por su cuenta usando
  solo los argumentos, sí las mezclaría: use la clave que le llega, no una propia.
- **Dentro de la misma conversación, la misma operación con los mismos argumentos es la misma
  operación.** Si el cliente quiere de verdad dos citas idénticas, algún argumento tendrá que
  distinguirlas; si no, la segunda le devolverá el resultado de la primera. En la práctica es el
  comportamiento correcto: casi siempre que eso ocurre es un doble intento del modelo.

**Por qué.** Hay dos causas reales de repetición, no hipótesis:

1. El modelo puede emitir la misma llamada dos veces en un mismo turno.
2. El transporte ya no reanuda flujos cortados: una respuesta interrumpida se reintenta como
   petición nueva.

**Si falta:** casos, citas o cotizaciones duplicados.

---

## 2 · Propiedad del registro

Toda operación sobre una entidad valida que **pertenece al contacto de la conversación**, no
solo que el identificador existe.

```
❌  cancelar_cita(cita_id)          → busca la cita y la cancela
✅  cancelar_cita(cita_id)          → busca la cita, verifica que su titular
                                      es el contacto en curso, y solo entonces cancela
```

**Un punto del diseño que conviene tener claro:** el Gateway **no le envía una identidad
autenticada del usuario final**. La conversación ocurre en WhatsApp, en un chat web o por teléfono;
ahí no hay sesión ni token de usuario. Quien dice ser el cliente lo afirma hablando.

Consecuencia práctica, y es responsabilidad suya:

1. Exponga una herramienta de **identificación** (documento, teléfono o correo) y haga que las
   operaciones sensibles exijan ese identificador como parámetro.
2. Valide que la entidad objetivo pertenece a ese identificador. Con
   `com.asixto/conversationId` puede además detectar si el identificador cambió a mitad de
   conversación, que es una señal de alarma que vale la pena registrar.
3. Para operaciones de alto impacto, no se conforme con el identificador: exija un dato que solo el
   titular conoce, o un código que su sistema envíe por su propio canal.

Si no coincide, devuelva `error_type: "PERMISSION"` (no un 403 crudo) para que el agente lo explique
y escale.

**Si falta:** un usuario cancela la cita de otro o corrige datos ajenos mencionando un
identificador plausible. Con un modelo de lenguaje de intermediario, un identificador plausible es fácil de producir.

---

## 3 · Guardas de estado terminal

Rechace transiciones inválidas con un error de negocio claro:

| Entidad | Estado terminal | Rechazar |
|---|---|---|
| Cita | cancelada, atendida | modificar, cancelar de nuevo |
| Oportunidad | cerrada ganada o perdida | mover etapa, reabrir |
| Cotización | aceptada, rechazada, vencida | volver a resolver, reenviar |
| Caso | cerrado | reabrir sin caso nuevo |

**Si falta:** el agente insiste, el usuario recibe una confirmación falsa y su sistema queda con
estados imposibles.

---

### 3.1 · No confíe en el orden dentro de un turno

El modelo puede pedir dos herramientas en el mismo turno (por ejemplo, consultar franjas y crear la
cita) y **el orden en que las recibe su servidor no está garantizado**: el runtime solo garantiza
precedencia en un caso concreto de agenda, no en general.

Consecuencia: **toda mutación revalida sus propios identificadores** contra su sistema en el momento
de ejecutarse. No asuma que la lectura que resolvió el id ya ocurrió, ni que el estado que devolvió
sigue vigente. Si el id no resuelve o el estado cambió, rechace con `VALIDATION`
([página 06](./06-errors.md)) en vez de operar sobre algo que ya no existe.

---

## 4 · El dato crítico lo decide su sistema

Fechas, montos, existencias e identificadores se **resuelven y validan en su lado**. Si el
agente propone algo inconsistente, rechácelo con un error entendible en vez de aceptarlo.

| Dato | Regla |
|---|---|
| Fecha y hora | Valide contra su disponibilidad real, al nivel de **instante**, no de día: una franja de hoy cuya hora ya pasó es pasada. Rechace lo ocupado aunque venga solicitado. |
| Monto | Recalcule desde su catálogo y su lista de precios. Nunca acepte el total que le manden. |
| Existencia | Verifique en el momento de la operación, no en el de la consulta. |
| Identificador de entidad | Resuelva contra lo que el cliente ya vio en la conversación. Si es ambiguo, devuelva error de ambigüedad en vez de elegir. |

**Por qué.** Es el fallo más costoso de todos: un modelo interpreta «el próximo martes» y
acierta casi siempre. Un acierto «casi siempre» no es suficiente para agendar ni para cobrar.

---

## 5 · Salida mínima y saneada

Devuelva solo lo necesario para responder. Prohibido: identificadores internos, claves
primarias, datos de terceros, campos administrativos, trazas, tokens, rutas internas.

El resultado entra al contexto del modelo, queda en la traza de la conversación y, en una
llamada telefónica, puede terminar leído en voz alta.

Límite duro: **3.000 caracteres** por respuesta (ahí recorta el Gateway) y el `limit` que reciba
como tope de elementos ([página 07](./07-limits.md)).

---

## 6 · Error con el tipo canónico

Todo error lleva `error_type` (uno de los **seis** valores canónicos) y un `user_message` apto para
el usuario final. Forma exacta y tabla de los seis en la [página 06](./06-errors.md).

La distinción que más importa: **un dato ausente y un sistema no disponible son eventos
distintos**. Si los colapsa, el agente dirá «no tenemos eso» cuando la causa real es que su sistema no
responde, y lo dirá con la misma seguridad con la que dice todo lo demás.

Y el caso que más veces se implementa mal: **una búsqueda sin resultados no es un error**. Es un
éxito con lista vacía.

---

## Cómo se verifica

| Garantía | Comprobación de la certificación |
|---|---|
| 1 | Dos llamadas con la misma clave en `_meta`: el efecto debe ocurrir **una sola vez** |
| 2 | Llamada con un identificador que no pertenece al contacto: `PERMISSION` |
| 3 | Transición sobre una entidad en estado terminal: `VALIDATION` con el estado actual |
| 3.1 | Mutación con un identificador que la lectura del mismo turno no devolvió: rechazada |
| 4 | Franja ocupada y monto alterado: deben rechazarse |
| 5 | Inspección de la respuesta: sin identificadores internos ni datos de terceros, ≤ 3.000 caracteres |
| 6 | Provocar tres casos distinguibles: sin resultados (éxito), dato inexistente (`NOT_FOUND`) y dependencia caída (`SYSTEM`) |

Las comprobaciones que escriben se ejecutan **solo** contra el entorno de pruebas que usted
declara.

---

<!-- fuente: https://gateway.asixto.com/docs/06-errors.md -->

# 06 · Errores

Cómo debe fallar su herramienta para que el agente reaccione bien.

Esta página no es una convención genérica: los seis valores de `error_type` son el **contrato
canónico que el runtime de Asixto ya consume hoy** para decidir qué hace el agente (reintentar,
quedarse en el paso, cambiar de flujo o escalar a un humano). Un valor fuera de esta lista se trata
como `UNKNOWN`.

## Forma del error

Un fallo de negocio **no es** un error de transporte: devuelva la respuesta de la herramienta con el
error dentro, no un 500.

El objeto de abajo va en el **`structuredContent`** de la respuesta, con **`isError: true`**. La
forma exacta y un ejemplo completo están en la [página 02](./02-tool-contract.md): si lo devuelve en
`content` como texto, el runtime no lo reconoce y trata el fallo como error técnico.

```json
{
  "error": {
    "error_type": "NOT_FOUND",
    "user_message": "No encontré una cita con esos datos."
  }
}
```

| Campo | Exigencia | Uso |
|---|---|---|
| `error_type` | **DEBE** | Uno de los seis valores de la tabla siguiente, exactamente con esa grafía. Determina la reacción del agente. |
| `user_message` | **DEBE** | Frase corta, sin jerga ni identificadores, en el idioma que llega en `com.asixto/locale` ([página 02](./02-tool-contract.md)); español neutro si no viene. El agente la parafrasea o la lee en voz alta. |
| `details` | PUEDE | Detalle técnico. Asixto **lo ignora**: no llega al modelo ni al usuario, pero cuenta para el límite de 3.000 caracteres ([página 07](./07-limits.md)) y viaja por la red. Envíelo solo si le sirve en su propia traza, y sin datos internos. |

No añada otros campos esperando que el agente los interprete: no hay nada más en el contrato. En
particular, **no existe una marca de "reintentable"**: la decisión de reintentar la deriva el agente
del `error_type`, según la tabla.

## Los seis valores de `error_type`

| `error_type` | Cuándo usarlo | Qué hace el agente |
|---|---|---|
| `NOT_FOUND` | La entidad no existe con los datos dados. | Vuelve al paso de identificación y pide verificar los datos. |
| `VALIDATION` | El dato viene mal formado, incumple una regla que el esquema no puede expresar, el estado no permite la operación o hay ambigüedad. | Pide la corrección **sin avanzar de paso**. Es el más útil y el que más va a usar. |
| `PERMISSION` | La operación no le corresponde a ese cliente, o la entidad no le pertenece. | Escala a un humano. **Salvo en llamada telefónica**: ahí el escalado no existe como acción y el agente solo puede ofrecer transferir. |
| `SYSTEM` | Fallo técnico transitorio: su dependencia no responde, timeout, 5xx. | Se disculpa y permite reintentar. **Nunca** dice que el dato no existe. |
| `BLOCKER` | El cliente tiene un impedimento que le prohíbe la operación: mora, suspensión, cuenta bloqueada. | Lo explica con tacto y **cambia de flujo** hacia facturación. Lea el aviso de abajo antes de usarlo. |
| `UNKNOWN` | Cualquier cosa no clasificada. | **Ninguna reacción definida.** Úselo como último recurso: en la práctica el agente improvisa con el `user_message`. |

`PERMISSION` cubre dos cosas que en su sistema pueden ser distintas ("no tiene permiso" y "esa cita
no es suya"): las dos deben terminar en escalado, así que van con el mismo valor.

> **Cuidado con `BLOCKER`.** Es el único valor que **cambia el flujo de la conversación** hacia el
> escenario de facturación. Si ese escenario no está habilitado para su tenant, el agente pierde el
> resto del catálogo en esa conversación y se queda solo con lo básico. Úselo únicamente para
> impedimentos reales de cartera, y coordine con nosotros que el escenario de facturación esté
> activo. Para «este cliente no puede hacer esto por su estado», use `VALIDATION`.

## Lo que NO es un error

| Situación | Respuesta correcta |
|---|---|
| Búsqueda válida sin resultados | **Éxito** con lista vacía y `total: 0`. Ver el aviso de abajo. |
| El cliente pide una franja ocupada | `VALIDATION`, con las alternativas disponibles en la respuesta. |
| Dato ambiguo (varios candidatos) | `VALIDATION` y un `user_message` que pida precisar. Nunca elija por él. |
| Entidad en estado terminal (cita cancelada, oportunidad cerrada) | `VALIDATION`, y diga en el `user_message` cuál es el estado actual. |
| Su servidor está limitando por cuota | `SYSTEM`. Para el agente es un fallo transitorio, que es lo correcto. |
| Su herramienta tarda más de 30 s | No hay nada que devolver: el runtime ya cortó. Diseñe para no llegar ahí ([página 07](./07-limits.md)). |

> **Catálogo vacío ≠ error.** Es el error de integración más frecuente y el más caro. Si devuelve
> `error` cuando simplemente no hay resultados, el agente entra en la rama de fallo: reintenta,
> retrocede de paso o se disculpa por un problema técnico que no existe. Devuelva
> `{"resultados": [], "total": 0}` y déjelo hablar con naturalidad.

## La distinción que más importa

```
❌  catálogo vacío  →  UNKNOWN / SYSTEM  →  "tengo un problema técnico" (falso)
❌  su BD caída     →  NOT_FOUND         →  "no tenemos ese producto"   (falso y grave)
✅  catálogo vacío  →  éxito, total 0    →  "no encontré ese producto"
✅  su BD caída     →  SYSTEM            →  "no puedo consultarlo ahora, ¿lo intentamos de nuevo?"
```

Colapsar los dos casos en un solo valor tiene una consecuencia concreta: el agente le dice a un
cliente real que usted no vende algo que sí vende, y lo dice con total naturalidad.

## Errores de protocolo

Reserve los fallos de transporte para lo que realmente lo es:

| Situación | Respuesta correcta |
|---|---|
| Falta la credencial o es inválida | **401** con `WWW-Authenticate` |
| Entrada que no cumple el `inputSchema` | Error de contrato del SDK (lo genera el propio SDK al validar) |
| Versión de protocolo no soportada | El error de versión previsto por el estándar, no un 500 |
| Herramienta inexistente | Error de método del protocolo |
| Excepción no controlada | Capture y devuelva `error_type: "SYSTEM"`. Un 500 sin cuerpo deja al agente sin nada que decir |

## Reglas de redacción de `user_message`

- Máximo una o dos frases. Se puede escuchar por teléfono.
- Sin identificadores, códigos internos, nombres de tabla ni de sistema.
- Sin culpar al usuario y sin disculpas largas: el agente pone el tono.
- Sin instrucciones al modelo (`"pide el documento otra vez"`): eso lo decide el `error_type`.
- Sin datos personales de terceros, aunque sea para desambiguar.

---

<!-- fuente: https://gateway.asixto.com/docs/07-limits.md -->

# 07 · Límites y rendimiento

Esta página fija los límites de tamaño, latencia y paginación que aplica el Gateway, y las reglas de
forma del canal de voz. Los números salen del sistema que va a ejecutar sus herramientas, no de una
recomendación genérica: hay una conversación esperando la respuesta y, en una llamada telefónica, el
silencio es audible.

## Límites del contrato

| Límite | Valor | Qué pasa si lo excede |
|---|---|---|
| Tamaño del resultado | **≤ 3.000 caracteres** | El runtime **recorta ahí** y marca el truncado. No es un error: su servidor no se entera y el agente responde con datos incompletos. |
| Resultados por página | **el `limit` que reciba** (hasta 25); **10** si no viene; **3** si el canal es `voice`, aunque el `limit` recibido sea mayor | El agente pide más cuando necesita comparar. Devolver menos de lo pedido le quita contexto; devolver más se pierde por recorte. |
| Latencia (p95) | **objetivo ≤ 2 s** | No hay corte a los 2 s, pero por encima de ~3 s la llamada telefónica acumula silencios audibles. Es el número que debe perseguir. |
| Tiempo de espera máximo | **30 s** | El runtime corta, el agente recibe un fallo transitorio y la conversación sigue sin la herramienta. A los 30 s la mayoría de las llamadas ya se han cortado: trate el tiempo de espera como límite, no como objetivo. |
| Herramientas por tenant | **≤ 40 recomendado** | No es un tope técnico: es precisión. Un catálogo grande degrada la elección del modelo. Se resuelve segmentando por escenario, que ya es como se activan ([página 04](./04-capabilities.md)). |
| Disponibilidad mensual | **≥ 99,5%** | Es su compromiso, por **mes natural**, medido por el Gateway sobre las llamadas del periodo: cuenta como indisponible la llamada que no recibe respuesta antes del tiempo de espera o que responde `5xx`. La vigilancia desactiva el escenario afectado cuando el servidor deja de responder (ver [página 08](./08-certification.md)). |

## Presupuesto de latencia en una llamada

Un turno de voz completo (transcribir, decidir, ejecutar la herramienta y sintetizar la respuesta)
tiene un presupuesto de pocos segundos. **Su herramienta es una parte de ese total, no el total.**

Si una operación no puede cumplirlo, no la haga sincrónica: expóngala como dos herramientas (una
que la inicia y devuelve un identificador, otra que consulta su estado) y deje que el agente informe
al usuario mientras tanto.

## Cambios de catálogo

Un cambio en su `tools/list` **no se aplica a las conversaciones ya abiertas**: alterar el conjunto
de herramientas a mitad de una conversación invalida la caché del modelo y degrada su comportamiento.
Entra en las conversaciones nuevas, con un retardo de hasta 15 minutos si su servidor no notifica el
cambio. Planifique los cambios de contrato como despliegues, no como ajustes en caliente.

## Paginación

Toda búsqueda **DEBE** aceptar `limit` y `offset` numéricos y devolver el total.

```json
{
  "resultados": [ /* ≤ 10 elementos, los más relevantes primero */ ],
  "total": 148,
  "limit": 10,
  "offset": 0
}
```

El agente usa `total` para decir «encontré 148, le menciono los más relevantes» en vez de enumerar.
Respete el `limit` que le llegue: puede pedir hasta 25 en chat cuando necesita comparar, y si no
manda `limit`, devuelva 10. Un cursor opaco es aceptable **además** de `offset`, nunca en su lugar.

## Reglas de forma para el canal de voz

Cuando `_meta` trae `com.asixto/channel: "voice"`, su respuesta se convierte en habla. Los datos son
los mismos; el formato cambia.

| Regla | Exigencia | Motivo |
|---|---|---|
| Máximo **3 elementos** en cualquier lista | **DEBE** | El agente solo alcanza a verbalizar tres antes de perder la atención de quien escucha. |
| **Sin URLs, correos, @handles ni nombres de archivo** en los campos de texto | **DEBE** | El Gateway los elimina antes de sintetizar la voz. Un dato que solo existe como URL no llega al cliente en una llamada. |
| Fechas y horas **en ISO y también en texto pronunciable** | **DEBE** | `"2026-09-03T09:00:00-05:00"` más `"jueves 3 de septiembre a las nueve de la mañana"`. La conversión no la haga el modelo: es un dato crítico ([garantía 4](./05-guarantees.md)). |
| Importes y cantidades redondeados, con moneda | **DEBERÍA** | «ochenta y nueve mil novecientos pesos» se sintetiza bien; `89900.00 COP` no. |
| Sin tablas, viñetas ni markdown | **DEBERÍA** | El texto se lee literal. |

## Diseñe para la voz, no para un panel

Una respuesta pensada para una tabla en pantalla no funciona en una llamada. Devuelva **pocos
campos, ya resueltos**:

| En vez de | Devuelva |
|---|---|
| `id_producto: "8f2a-…"` | `nombre: "Plan Fibra 300"` |
| `precio_base`, `iva`, `descuento` | `precio_final: 89900` más `moneda: "COP"` |
| `fecha: "2026-09-03T14:00:00Z"` | la fecha ISO **y** `fecha_texto` pronunciable |
| 25 campos del registro | Los 4 o 5 que el usuario puede preguntar |

## Cuota y protección

El Gateway acota las **rondas de llamadas por turno** y descarta las llamadas repetidas con la misma
firma, así que un bucle del modelo no llega a su servidor como tráfico infinito. Ese tope es del
runtime y no es una cuota contra la que deba dimensionar: dimensione por **conversaciones
concurrentes** ([página 10](./10-operation.md)).

Aun así, su servidor es un endpoint expuesto a internet: aplique sus propios límites por credencial.
Puede rechazar con **429** en el transporte o devolver `error_type: "SYSTEM"` en el resultado de la
herramienta; el Gateway trata las dos formas como fallo transitorio y la conversación sigue
([página 06](./06-errors.md)).

## Observabilidad recomendada

Registre por invocación: nombre de la herramienta, `com.asixto/requestId`, duración, resultado y
`error_type`. Con eso, cualquier incidente se cierra citando un identificador, sin que Asixto
necesite acceso a su infraestructura.

**DEBERÍA** exponer además una comprobación de salud independiente del endpoint MCP, que responda
`200` cuando su servidor puede atender y un `5xx` cuando no. Asixto **no la sondea** —la salud se
mide por las llamadas reales, que es lo que gobierna la vigilancia de la
[página 08](./08-certification.md)—: es para su propia operación y para acortar un incidente.

---

<!-- fuente: https://gateway.asixto.com/docs/08-certification.md -->

# 08 · Certificación

Esta página lista las comprobaciones que su servidor debe pasar antes de entrar en producción y en
qué orden ocurre cada fase. No revisamos su código: usted registra el servidor, lo evaluamos contra
esta lista y recibe el mismo informe que vemos nosotros.

> **Qué mide la batería hoy, y qué revisamos con su equipo.** De las 34 comprobaciones, **12 las mide
> la batería automática** en cada corrida y **22 las verificamos junto a su equipo** durante la
> incorporación —19 de ellas bloqueantes—. La columna «Cómo se comprueba» de cada tabla lo dice fila
> por fila, y su informe también: una fila con **«Todavía no automatizada»** no es un defecto suyo ni
> algo que cambie por repetir la corrida.
>
> Consecuencia práctica, dicha sin rodeos: el sello **«Apta para producción»** exige las 34, así que
> hoy **no se alcanza solo lanzando corridas**. Lo que usted puede cerrar por su cuenta es
> **«Batería automática superada»**, y es lo que la aplicación le muestra como objetivo. La lista de
> abajo no cambia por eso: es el criterio de aceptación completo, y conviene que la use como su
> propia lista de pruebas desde el primer día.

## Fases

| # | Fase | Qué ocurre |
|---|---|---|
| 1 | **Registro** | Da de alta en la aplicación cuatro datos: la URL de producción, la URL de su **entorno de pruebas**, la credencial que su servidor de pruebas espera y un contacto técnico. La **era de protocolo no se declara**: al registrar hablamos con su entorno de pruebas y la detectamos nosotros ([página 09](./09-changelog.md)). Por eso el registro tiene una precondición: **su entorno de pruebas tiene que estar desplegado y respondiendo en ese momento.** Si no acordamos revisión con él, el alta se rechaza diciéndole qué anunció y qué desplegar; si el que falla es nuestro lado, se lo decimos con esas palabras y basta reintentar. Por lo demás no se declaran endpoints ni esquemas, y el catálogo no se configura en ningún panel: cada herramienta llega ya clasificada por el `_meta` de su definición ([página 02](./02-tool-contract.md)). |
| 2 | **Certificación** | La batería corre de inmediato contra su entorno de pruebas y devuelve un informe con las 34 filas: las que aprueba, las que falla, las que no pudo medir y las que verificamos con su equipo. Sin aprobación no hay activación. |
| 3 | **Publicación del catálogo** | Usted ordena publicar y leemos `tools/list` de su servidor en el entorno que elija: sus herramientas quedan disponibles para el agente. **Certificar no publica**: son dos actos, para que usted decida cuándo la versión que sirve su servidor pasa a usarse. Las que no se admitan vienen enumeradas con su motivo y no impiden publicar el resto. |
| 4 | **Conversaciones de prueba** | Prueba a su agente, con sus herramientas, sin tráfico de clientes finales y con una clave `ak_test_`: por chat en el **Playground** de la consola y por voz llamando a sus **números de prueba verificados** ([página 14](./14-api-quickstart.md)). Se cobra como cualquier consumo ([página 12](./12-pricing.md)). |
| 5 | **Activación** | Se enciende escenario por escenario, con su nivel y sus interruptores por acción. Usted decide alcance y canales. |
| 6 | **Vigilancia** | La batería se repite de forma periódica y ante cada cambio de catálogo; una regresión desactiva la capacidad. Es la última pieza en entrar: hasta entonces, la vigilancia es por monitoreo de errores y aviso a su equipo. |

Las comprobaciones que escriben se ejecutan **solo** contra el entorno de pruebas declarado. La
batería nunca escribe en su producción.

## Verifíquelo usted antes de avisarnos

La mayor parte de esta lista se comprueba sola. El servidor de referencia trae el verificador como
comando y **corre contra cualquier servidor MCP**, no solo contra él:

```bash
git clone https://github.com/AndresTorresCode/asixto-mcp-reference
cd asixto-mcp-reference && npm install
node scripts/conformance.mjs https://su-entorno-de-pruebas/mcp SU-TOKEN --allow-writes
```

Sale con código 1 si falla una comprobación bloqueante, así que sirve en su pipeline. Imprime además
la **era de protocolo** detectada: ya no hay que copiárnosla —la detectamos nosotros al registrar—,
pero verla antes le confirma que su entorno de pruebas acuerda revisión y que el alta va a pasar.

**Es de solo lectura por omisión.** La comprobación de idempotencia es la única que ejecuta una
herramienta de escritura —dos veces, con la misma clave— y solo corre con `--allow-writes`. Sin la
bandera, «0 bloqueantes» **no** significa que la idempotencia esté verificada, y el veredicto se lo
dice con esas palabras. Por eso el comando de arriba apunta a su entorno de pruebas.

Lo que el verificador **no** cubre: latencia bajo carga, propiedad del registro con datos reales,
estados terminales y los campos mínimos por dominio de la [página 04](./04-capabilities.md). Esas
cuatro tampoco las mide la batería automática todavía —salen en su informe como «Todavía no
automatizada»—: las revisamos con su equipo con datos suyos, que es la única forma de comprobarlas
sin escribir en su producción.

## Comprobaciones

`Bloqueante` impide la activación. `Aviso` queda en el informe y no bloquea.

**Cómo leer su informe.** Cada fila sale con uno de cuatro resultados, y cada uno dice de quién es la
siguiente acción:

| Resultado | Qué significa | Qué hace usted |
|---|---|---|
| **Cumplida** | La batería la midió y su servidor la pasa | Nada |
| **Fallida** (bloqueante) | La batería la midió y su servidor no la pasa. El detalle trae qué corregir | Corregir y volver a lanzar |
| **Recomendación** (aviso) | La midió y su servidor no la cumple, pero **no bloquea**: ni la certificación ni la activación | Nada obligatorio. Puede dejarlo como está |
| **No observada** | La batería no llegó a un veredicto. El detalle dice **de quién es el motivo**, porque hay dos: un fallo de nuestro lado (no pudimos leer su catálogo, no llegamos a ejercer una herramienta) o una respuesta suya que no sirve para comparar (su servidor rechazó los argumentos de prueba, o no devolvió resultado) | Si el motivo es nuestro, volver a lanzar en unos minutos y, si persiste, avisarnos. **Si su servidor rechazó los argumentos de prueba, relanzar da lo mismo**: el detalle trae su propio mensaje de error, y se corrige en su esquema o en su validación de pruebas |
| **Todavía no automatizada** | La batería no ejecuta esta comprobación: la revisamos con su equipo | Nada en la aplicación. Puede adelantarla con el verificador público cuando la cubra |
| **No aplica** | Su catálogo no expone el sujeto que la comprobación necesita: sin una herramienta que CREE una entidad del dominio no hay nada que llevar a estado terminal ni cuya propiedad se pueda probar. **No es una deficiencia y no bloquea la aptitud** | Nada. Si más adelante publica esa herramienta, la fila se mide sola en la siguiente corrida |

Si su servidor no responde, responde con un estado inservible, rechaza la credencial del alta o
devuelve algo que no es un sobre JSON-RPC, **eso sí sale como fallida y con instrucciones**: que
responda —aunque responda mal— nos permite decirle exactamente qué arreglar.

### Protocolo y transporte

| Comprobación | Criterio | Severidad | Cómo se comprueba |
|---|---|---|---|
| Era de protocolo coherente | `server/discover` anuncia `supportedVersions` con `2026-07-28` (era *modern*) **o** el servidor responde al saludo `initialize` con una revisión que soporta (era *legacy*). Las dos se admiten y la era la DETECTAMOS al dar de alta contra su entorno de pruebas (fase 1), así que aquí ya llega medida; lo que bloquea es ninguna de las dos ([página 09](./09-changelog.md)) | Bloqueante | Automática |
| Era recomendada | La era es *modern*. Un servidor de revisión anterior **se certifica igual y puede quedarse así**: migrar es opcional | Aviso | Automática |
| Catálogo válido | Todos los esquemas compilan y los nombres son únicos | Bloqueante | Automática |
| Esquemas cerrados | `additionalProperties: false` | Bloqueante | Automática |
| **Opcionales nullable** | Todo parámetro fuera de `required` admite `null` | Bloqueante | Automática |
| `required` mínimo | Sin campos exigidos que su sistema pueda resolver por defecto | Aviso | Con su equipo |
| Nombres y longitudes | Nombre ≤ 40 y `^[a-z0-9_]+$`; `description` ≤ 700 | Bloqueante | Automática |
| Una operación por herramienta | Sin parámetro que multiplexe acciones. La batería **refuta** un caso concluyente: un parámetro cuyo `enum` deja elegir entre un método HTTP de lectura (`GET`, `HEAD`, `OPTIONS`) y uno que escribe (`POST`, `PUT`, `PATCH`, `DELETE`) no es una operación, es un proxy con interruptor. El resto —distinguir un enum de modos legítimos de un multiplexor de acciones— lo revisamos con su equipo | Bloqueante | Con su equipo |
| Rechazo de entrada inválida | Fuera de esquema: error de contrato del SDK. Dentro de esquema pero inválida para el negocio: `error_type: "VALIDATION"`. En ningún caso un fallo del servidor | Bloqueante | Con su equipo |
| Anotaciones coherentes | Una escritura no se declara de solo lectura. Que estén **declaradas** lo comprueba la batería; que sean **ciertas** exige ejecutar la herramienta | Bloqueante | Con su equipo |
| **Clasificación presente** | Cada herramienta trae `com.asixto/capability` (o `capabilities` en plural) con claves válidas, y `com.asixto/verb` | Bloqueante | Automática |
| **Campos mínimos por dominio** | La respuesta trae lo exigido en la página 04 | Bloqueante | Con su equipo |
| Orden estable del catálogo | Dos consultas, mismo orden | Aviso | Automática |
| Descripciones limpias | Sin instrucciones dirigidas al modelo («llama siempre a esta herramienta»), sin `system prompt` y sin bloques que parezcan texto codificado. Es estilo, y por eso es aviso y no bloqueante: sesga la elección del modelo y el precio lo paga usted. `system prompt` está aquí y no en la fila bloqueante a propósito — si su producto es un asistente, documentarlo es legítimo | Aviso | Automática |
| **Descripciones sin inyección** | Ningún texto de la herramienta intenta gobernar al modelo: sin «ignora las instrucciones anteriores», sin pedirle que no use otras herramientas, sin marcas de rol (`<system>`, `[INST]`) y **sin caracteres invisibles** (ancho cero, control de dirección, etiquetas `U+E0000`). Se revisa la descripción **y los textos de su `inputSchema`** —`description`, `title`, `$comment` de cada campo—, porque el modelo los lee igual. La comparación normaliza el texto, así que escribirlo en ancho completo o con doble espacio no lo esquiva. Estos textos viajan a nuestro modelo en el mismo contexto que las instrucciones de la empresa, así que no es una recomendación de estilo: **una herramienta que lo incumpla tampoco se publica** ([página 02](./02-tool-contract.md)) | Bloqueante | Automática |
| **Entorno de pruebas del mismo servicio** | La URL de pruebas y la de producción comparten dominio. Un dominio distinto **no bloquea nada** y puede ser lo correcto: solo le avisamos, con los dos hosts, para que compruebe que esa URL es su servidor MCP de pruebas y no otro servicio suyo — la batería certifica lo que responda ahí | Aviso | Automática |

### Autenticación

| Comprobación | Criterio | Severidad | Cómo se comprueba |
|---|---|---|---|
| Sin credencial | 401 con `WWW-Authenticate` | Bloqueante | Con su equipo |
| Credencial inválida o caducada | 401, no 500 | Bloqueante | Con su equipo |
| Audiencia ajena | Rechazada | Bloqueante | Con su equipo |
| Metadatos de recurso protegido (RFC 9728) | Publicados y coherentes. Aviso porque el nivel 2 (token rotable) no los requiere | Aviso | Con su equipo |
| TLS | Certificado válido y vigente | Bloqueante | Con su equipo |

### Garantías de negocio

| Comprobación | Criterio | Severidad | Cómo se comprueba |
|---|---|---|---|
| Idempotencia | Misma clave de `_meta` dos veces, un solo efecto. Se ejerce **cada** herramienta de escritura de su catálogo, y solo contra su entorno de pruebas. Una que no la honre queda además marcada **no apta para regla determinista**. **Los argumentos los derivamos de su `inputSchema`, y solo los `required`**: un `enum` toma su primera opción, un número su `minimum` (o `1`), un booleano `false` y un texto la letra `X` repetida hasta su `minLength` (mínimo 6). Si su entorno de pruebas exige que esos valores existan de verdad —el id de un préstamo, de una llamada— la rechazará, y la fila sale **no observada** con el mensaje de error de su servidor, no aprobada ni fallida | Bloqueante | Automática |
| Propiedad del registro | Identificador ajeno → `PERMISSION` | Bloqueante | Con su equipo |
| Estado terminal | Transición inválida → `VALIDATION` con el estado actual | Bloqueante | Con su equipo |
| Dato crítico | Franja ocupada y monto alterado rechazados | Bloqueante | Con su equipo |
| Fuga de datos | Sin identificadores internos, datos de terceros ni trazas | Bloqueante | Con su equipo |
| Forma del error | `error_type` de los seis canónicos + `user_message` | Bloqueante | Con su equipo |
| **Sin resultados = éxito** | Búsqueda vacía devuelve lista vacía y `total: 0`, no `error` | Bloqueante | Con su equipo |
| Vacío vs caído vs inexistente | Tres respuestas distinguibles | Bloqueante | Con su equipo |

### Rendimiento

| Comprobación | Criterio | Severidad | Cómo se comprueba |
|---|---|---|---|
| Latencia p95 | ≤ 2 s sobre la muestra de la batería | Aviso | Con su equipo |
| Tiempo de espera | Ninguna llamada supera 30 s | Bloqueante | Con su equipo |
| Tamaño de respuesta | ≤ 3.000 caracteres | Bloqueante | Con su equipo |
| Paginación | Búsquedas con `limit`, `offset` y `total`, respetando el `limit` recibido | Bloqueante | Con su equipo |
| Forma para voz | Sin URLs ni correos; ≤ 3 elementos; fecha ISO + pronunciable | Bloqueante | Con su equipo |

## Autoevaluación previa

Antes de registrar, verifique por su cuenta. Ahorra una vuelta completa:

- [ ] Cada herramienta probada con el Inspector y con un test automatizado.
- [ ] Esquemas cerrados en todas.
- [ ] 401 correcto sin credencial y con credencial inválida.
- [ ] Todo parámetro opcional acepta `null` en su `type`.
- [ ] Escritura invocada dos veces con la misma clave de `_meta`: un solo efecto.
- [ ] Escritura invocada con un identificador de otro contacto: `PERMISSION`.
- [ ] Operación sobre una entidad en estado terminal: `VALIDATION`.
- [ ] Búsqueda sin resultados: **éxito** con lista vacía y `total: 0`, no `error`.
- [ ] Respuestas revisadas a ojo: sin identificadores internos ni campos administrativos, ≤ 3.000 caracteres.
- [ ] Solo los seis `error_type` de la [página 06](./06-errors.md).
- [ ] Búsquedas respetan el `limit` recibido y devuelven `total`, `limit` y `offset`.
- [ ] Nombres ≤ 40 caracteres y descripciones ≤ 700.
- [ ] Cada herramienta declara `readOnlyHint` con un booleano. **Sin él no se publica**: no podemos saber si escribe.
- [ ] Ninguna descripción da instrucciones al modelo ni trae caracteres invisibles.
- [ ] Ninguna herramienta deja elegir el método HTTP: una de lectura y una por cada acción que escribe.
- [ ] Cada herramienta declara `com.asixto/capability` o `capabilities` (claves válidas) y `com.asixto/verb`.
- [ ] Cada dominio devuelve sus campos mínimos ([página 04](./04-capabilities.md)).
- [ ] Ningún token en sus logs.

## Requisitos que no se verifican desde fuera

Estos no aparecen arriba porque **ninguna batería puede comprobarlos** desde el exterior de su
sistema. Forman parte del criterio de aceptación igualmente, y se declaran en el registro:

| Requisito | Página | Por qué no es verificable desde fuera |
|---|---|---|
| TTL ≥ 7 días y escritura atómica del almacén de idempotencia | [05](./05-guarantees.md) | La batería solo observa dos llamadas seguidas; no ve su almacén ni espera una semana. |
| Consultar la clave de idempotencia **antes** de las guardas | [05](./05-guarantees.md) | Desde fuera, el orden correcto y el incorrecto se parecen hasta que ocurre un reintento real sobre una entidad ya modificada. |
| Guardar solo los éxitos | [05](./05-guarantees.md) | Requiere provocar un fallo transitorio en su dependencia. |
| Rotación con dos credenciales activas | [03](./03-authentication.md) | Es un procedimiento operativo suyo. |
| Límites propios por credencial y comprobación de salud | [07](./07-limits.md) | Se verifican en la incorporación, no en cada ejecución. |
| Disponibilidad ≥ 99,5% | [07](./07-limits.md) | Se mide en producción a lo largo del mes, no en una batería. |
| No exponer borrado duro ni campos que el titular no deba cambiar | [04](./04-capabilities.md) · [02](./02-tool-contract.md) | Se revisa al leer su catálogo, que es la única parte donde interviene una persona. |

Dicho de otro modo: la batería comprueba **la forma**; lo de esta tabla es **compromiso**, y es lo
que se revisa si algún día hay un incidente.

## Qué certifica Asixto y qué no

La certificación verifica que su servidor cumple **este contrato**, observado desde fuera:
transporte, autenticación, garantías de negocio y rendimiento. Es determinista y repetible, y por
eso puede correr sin que nadie lea su código.

Lo que no es: una auditoría de su código, de su infraestructura o de sus datos, ni un sustituto de
sus propios controles. La operación, la disponibilidad, la seguridad y el comportamiento de su
servidor —y de los sistemas que hay detrás— son suyos. Asixto ejecuta la batería, le entrega el
resultado por comprobación y suspende un escenario cuando el servidor deja de responder
([página 10](./10-operation.md)); no administra ni supervisa su servidor.

Un resultado aprobado significa que el contrato se cumple en el momento de la ejecución. No es una
garantía sobre el contenido de sus respuestas: los datos que devuelve son suyos y el agente los
traslada al cliente final tal como los recibe ([página 05](./05-guarantees.md)).

## Recertificación

| Cambio | Requiere |
|---|---|
| Herramienta nueva | Certificación de esa herramienta |
| Cambio de esquema de una herramienta existente | Recertificación de esa herramienta |
| Renombrar una herramienta | Certificación como nueva; la anterior se retira |
| Cambio de credencial o de URL | Nueva verificación de autenticación |
| Cambio interno sin efecto en el contrato | Nada. La vigilancia periódica lo cubre |

---

<!-- fuente: https://gateway.asixto.com/docs/09-changelog.md -->

# 09 · Versionado y cambios

## Versión de este contrato

| Contrato | Revisión del protocolo | Estado |
|---|---|---|
| **v1** | MCP `2026-07-28` recomendada · revisiones anteriores admitidas | Vigente |

El contrato se ancla a `2026-07-28`, pero **eso no obliga a su servidor a hablarla**: un servidor de
la revisión anterior se certifica igual y se registra con su era. El detalle está abajo, en
[Las dos eras del protocolo](#las-dos-eras-del-protocolo-y-las-dos-se-admiten).

## Política de cambios

- **Cambio compatible** (herramienta nueva, campo opcional nuevo, código de error nuevo):
  se publica y entra en vigor de inmediato. No requiere acción de su parte.
- **Cambio incompatible** (campo obligatorio nuevo, límite más estricto, código de error
  retirado): se anuncia con **90 días** de antelación y las dos versiones conviven durante ese
  periodo.
- **Cambio de revisión del protocolo**: se adopta con un periodo de convivencia anunciado. La
  propia especificación incorpora desde `2026-07-28` una política formal de obsolescencia.
  **Un servidor ya certificado no deja de funcionar por un cambio de versión del estándar.**

## Cuatro cambios incompatibles para que la primera llamada salga

Entran juntos y **sin los 90 días de convivencia** de la política de arriba, con el acuerdo de la única cuenta
integrada, que no tenía ninguna llamada completada ni ningún consumo. Se declaran igual porque cambian
supuestos razonables de quien integra.

### 1 · En pruebas, solo números verificados, y hasta 5

Una clave `ak_test_` solo llama a un número de prueba **verificado** de su cuenta; hasta **5** por cuenta,
contando pendientes y verificados. La transferencia de la conducta de pruebas también va solo a uno verificado.

**Qué tiene que hacer.** Registre y verifique sus números en la consola, en **Números de prueba**
([página 14](./14-api-quickstart.md)). Llamar a otro responde `409 test_destination_not_verified`.

### 2 · En producción, número propio obligatorio

Una clave `ak_live_` ya **no** sale con un número de Asixto cuando su cuenta no tiene el suyo. Sin número
propio listo, la llamada no sale y no se cobra: `409 phone_number_required`, `409 phone_number_in_review` o
`402 phone_number_suspended`.

**Qué tiene que hacer.** Tenga su número y su expediente aprobados antes de pasar a `live`. Qué significa cada
código, en la [página 14](./14-api-quickstart.md).

### 3 · El excedente exige un medio de pago verificado

Con el consumo por excedente encendido, si su cuenta no tiene un medio de pago verificado, el excedente **no
cuenta**: cuando el saldo no cubre la operación, recibe `402 insufficient_credits`, como si estuviera apagado.

**Qué tiene que hacer.** Agregue un medio de pago en la consola, o recargue créditos.

### 4 · El entorno de pruebas ya no tiene techo de créditos propio

Lo que estas páginas publicaban como baranda —que un bucle de pruebas se cortaba contra el cupo de su entorno
antes de vaciar el presupuesto de producción— **se retira**. Una cuenta de la beta nace sin techo de créditos
por periodo en ningún entorno, y pruebas y producción gastan del mismo saldo.

**Qué tiene que hacer.** Fije un tope de gasto de la cuenta que le sirva también para sus pruebas
([página 16](./16-api-errors.md)).

## Tres cambios incompatibles ya en vigor

Van juntos porque los tres afectan a código o a presupuesto que quizá ya tiene decidido, y cada uno trae lo
que le toca hacer. **Los tres estaban en vigor antes de este aviso**, así que no tuvieron el periodo de
convivencia de la política de arriba. Se declara en vez de disimularse: es lo que le permite explicar un
`403` que no esperaba o un consumo que creía gratis.

### 1 · El entorno de pruebas cobra, y al mismo precio

Una clave `ak_test_` **ejecuta de verdad y consume créditos a los mismos precios** que una `ak_live_`
([página 12](./12-pricing.md)). No hay tarifa de pruebas ni descuento: el cobro no es un eje que distinga
entornos. Lo que separa `test` de `live` es la concurrencia propia de cada entorno, a quién puede llamar y el
aislamiento de sus datos, no el proveedor ni el precio.

Es una decisión con consecuencia, y por eso se publica en vez de dejarla en la letra pequeña: la
alternativa —devolverle una respuesta simulada— haría que certificar no significara nada, porque estaría
certificando contra un resultado que su cliente final nunca va a recibir.

**Qué tiene que hacer.** Cuente sus pruebas en su presupuesto, no como tráfico gratis. Lo que acota su gasto
es el tope de gasto de la cuenta ([página 16](./16-api-errors.md)): el entorno de pruebas ya no tiene un techo
de créditos propio (cambio 4 de la sección anterior).

### 2 · La identidad con la que sale la llamada, y `telephony_not_ready` retirado

El error `telephony_not_ready` **se retiró** y no vuelve: en `live` sin número propio listo lo que llega ahora es
uno de los tres códigos del cambio 2 de la sección anterior. Retirar un
código del catálogo es, por la política de arriba, un cambio incompatible, y así se clasifica aquí. La
descripción del cambio está en la entrada del **2026-09-08** del historial.

Lo que faltaba decir es el segundo eje, y es el que cambia lo que oye quien reciba la llamada:

| Entorno | Con qué número sale |
|---|---|
| `live` | El **suyo**, obligatorio: desde el cambio 2 de la sección anterior ya no sale uno de Asixto de reserva ([página 14](./14-api-quickstart.md)) |
| `test` | **Siempre** uno de Asixto: en pruebas el número del tenant no se mira, aunque lo tenga ([página 14](./14-api-quickstart.md)) |

**Qué tiene que hacer.** Si su código trataba `telephony_not_ready` como el caso «todavía no puedo llamar»,
retire esa rama: ya no llega. Y no dé por hecho en pruebas el número que verá en producción — si lo que
valida es el identificador de origen, valídelo con una clave `ak_live_`.

### 3 · Leer el stream del turno lo autoriza `jobs:read`

Abrir el turno y leer su stream son **dos permisos distintos**: `agent:converse` origina el trabajo,
`jobs:read` lee su respuesta ([página 19](./19-api-stream.md)). Una clave sin `jobs:read` paga el turno y
recibe `403 insufficient_scope` al abrir el stream ([página 16](./16-api-errors.md)).

Dos consecuencias, y las dos son contrato:

- **Toda clave nace con las lecturas que no gastan nada**: `jobs:read` y `agents:read`, se pidan o no,
  porque sin ellas quien ya integró no puede leer lo que pagó ni encontrar el agente al que llamar.
  `agent:converse` **no** se concede así: es irreversible y es la capacidad más cara de la superficie, y
  no se regala a una clave que se emitió para transcribir audio.
- **No existe ningún `agent:read` para leer un turno, y no se va a crear**: eso lo autoriza `jobs:read`.
  Leer la LISTA de sus agentes es otro recurso y tiene su propio permiso, `agents:read`
  ([página 15](./15-api-reference.md)). El vocabulario de scopes es cerrado: un nombre que no esté en él
  se rechaza al emitir, no al usar.

**Qué tiene que hacer.** Mire los scopes de las claves que ya tenía emitidas: si a alguna le falta
`jobs:read`, esa clave no puede leer sus propios trabajos ni sus streams, y si le falta `agents:read`
—porque se emitió antes de que ese permiso existiera— recibirá `403 insufficient_scope` al listar sus
agentes. El camino es el mismo en los dos casos: **emitir una clave nueva** desde la aplicación y rotarla
en su despliegue. Ampliar una clave viva concede exactamente lo que se pida, así que un permiso nuevo no
aparece solo en las que ya tiene.

## Compatible y ya en vigor: contexto por petición en las dos operaciones con agente

`POST /v1/calls` y `POST /v1/agent/turns` aceptan dos objetos opcionales nuevos, `variables` y `metadata`
([página 15](./15-api-reference.md)). Es un **cambio compatible**: sin ellos, las dos operaciones se
comportan exactamente igual que antes.

Lo que sí conviene que sepa aunque no los use:

- Las respuestas correctas pueden traer ahora una clave `warnings` de primer nivel. Es **opcional y ausente**
  cuando no hay nada que avisar, así que un receptor que ignore claves desconocidas no necesita cambios.
- Los eventos `job.*` pueden traer `metadata` dentro de `data` ([página 11](./11-webhooks.md)) — solo si
  usted lo mandó en la petición.
- Si su agente tiene huecos `{{clave}}` en su saludo o su mensaje de apertura y usted **no** manda
  `variables`, la petición se rechaza con `400 validation_error` nombrando la clave que falta. Un agente sin
  huecos no se ve afectado: es la configuración la que los introduce, y la hace usted en la consola.
- Repetir una `Idempotency-Key` con **otras** `variables` responde `409 idempotency_key_reused`. El reintento
  de la misma petición sigue siendo idempotente.

## Qué cambió en la revisión 2026-07-28 del protocolo

Relevante si su equipo conoce revisiones anteriores o encuentra ejemplos antiguos:

| Cambio | Efecto en su servidor |
|---|---|
| Protocolo **sin estado** | Desaparecen el saludo de inicialización y el identificador de sesión. Cada petición es autónoma; puede desplegar en *serverless* o *edge*. |
| Nuevo `server/discover` | Su servidor **debe** implementarlo para anunciar versiones, capacidades e identidad. |
| Fuera la reanudación de flujo | Una respuesta cortada se reintenta como petición nueva. Por eso la idempotencia es obligatoria. |
| `resultType` en todo resultado | El Gateway solo consume resultados completos. Si su servidor es de una revisión anterior y no emite el campo, se trata como `complete` (SEP-2322): no hay que hacer nada. |
| `subscriptions/listen` | Sustituye la suscripción anterior para avisar cambios de catálogo. Opcional. |
| Cabeceras `Mcp-Method` y `Mcp-Name` | Requeridas en cada POST. Las pone el SDK. |
| Orden determinista en `tools/list` | Recomendado por la especificación para preservar la caché del modelo. |
| Registro dinámico de cliente obsoleto | Sustituido por documentos de metadatos de cliente. No lo requiera. |

Los SDK oficiales de TypeScript, Python, Go y C# implementan esta revisión en su línea v2.

## Las dos eras del protocolo, y las dos se admiten

La especificación distingue dos formas de acordar qué se habla, y **Asixto acepta las dos**:

| Era | Cómo se reconoce | Estado |
|---|---|---|
| **modern** | `server/discover` anuncia `supportedVersions` con `2026-07-28`. La versión y las capacidades del cliente viajan en el `_meta` de cada petición; no hay estado de sesión. | Recomendada |
| **legacy** | Responde al saludo `initialize` con una revisión que soporta (`2025-11-25` o anterior). | Admitida |

Lo que **no** se admite es ninguna de las dos: sin `supportedVersions` y sin respuesta a
`initialize` no hay forma de acordar la revisión, y eso sí bloquea.

**La era NO se declara: la detectamos al dar de alta el servidor.** Al registrarlo hablamos con su
entorno de pruebas, leemos cómo acuerda la revisión y la guardamos; a partir de ahí el Gateway le
habla en la que corresponda y no la deduce en caliente. La consecuencia práctica es una
precondición: **su entorno de pruebas tiene que estar desplegado y respondiendo en el momento del
alta.** Si no acordamos revisión con él, el alta se rechaza diciéndole qué anunció y qué falta.

> **Cuidado con el caso intermedio.** Un servidor puede responder `server/discover` con el **cuerpo**
> de `initialize` —un `protocolVersion` en singular y la identidad en la raíz—. Eso es un servidor
> **legacy**, aunque el nombre del método sugiera lo contrario, y así lo clasifica el verificador:
> mira el contenido de la respuesta, no que el método exista. Si su documentación anuncia
> `2026-07-28`, conviene que el servidor lo cumpla o que la documentación diga la revisión real.

## Referencias

- Especificación del protocolo: <https://modelcontextprotocol.io/specification/2026-07-28>
- Cambios de la revisión: <https://modelcontextprotocol.io/specification/2026-07-28/changelog>
- SDK TypeScript v2: <https://ts.sdk.modelcontextprotocol.io/v2/>
- SDK Python v2: <https://py.sdk.modelcontextprotocol.io/>
- Inspector: <https://github.com/modelcontextprotocol/inspector>

## Historial de este documento

| Fecha | Versión | Cambio |
|---|---|---|
| 2026-08-14 | v1.0 | Publicación inicial. Contrato anclado a MCP `2026-07-28`. |
| 2026-08-26 | v1.0 | **Compatible.** Se admiten las **dos eras** del protocolo: un servidor de revisión anterior (`initialize`) se certifica igual y la era se declara al darlo de alta. Se aclara que la ausencia de `resultType` se trata como `complete`. Y la [página 08](./08-certification.md) publica el comando del verificador de conformidad. Ninguna de las tres cosas exige cambios en un servidor ya construido. |
| 2026-08-18 | v1.0 | **Compatible.** El `_meta` de `tools/call` suma `com.asixto/locale` (idioma de atención del tenant, BCP 47): los textos legibles van en ese idioma ([página 02](./02-tool-contract.md)). No exige cambios en su servidor: si lo ignora, siga devolviendo español neutro. |
| 2026-08-28 | v1.0 | **Compatible.** Nace la [página 13](./13-data.md): retención con cifra por tipo de dato —la entrada no se persiste, el resultado vive 24 h, la ventana de la sesión 24 h de inactividad—, la lista de los proveedores que reciben contenido suyo, el borrado a petición y qué no se cobra. La [página 10](./10-operation.md) deja de decir «la ventana acordada para su cuenta», que no era un dato verificable. Corrección de facturación en el mismo cambio: una respuesta que el sistema marca como no entregada ya no se cobra, y una cantidad que no se puede medir se cobra al mínimo de la operación y no al máximo autorizado. |
| 2026-08-29 | v1.0 | **Compatible.** Se publica la **referencia de la API** (etapa 2): [página 14](./14-api-quickstart.md) primeros pasos y entornos, [página 15](./15-api-reference.md) las siete operaciones campo por campo con ejemplos ejecutables, [página 16](./16-api-errors.md) el catálogo completo de errores con qué hacer en cada caso, y [página 17](./17-jobs.md) trabajos, retención del resultado y los cinco casos de una clave de idempotencia repetida. **Solo HTTP: no hay SDK.** Nada de esto cambia el contrato de un servidor de herramientas ya construido; lo que hace es publicar lo que la API ya hacía y que solo se podía averiguar preguntando. |
| 2026-09-08 | v1.0 | **Compatible.** Tres cambios que sí se notan. **(1)** El veredicto de una certificación admite un tercer valor, `inconclusive`: una corrida que no pudo OBSERVAR ninguna comprobación bloqueante ya no se rinde como aprobada. Si su código enruta el webhook `certification.finished` con un `if aprobada / else fallida`, ahora puede recibir un tercero, y significa «no lo pudimos comprobar», no «su servidor falló». **(2)** Se retira el error `telephony_not_ready`: **ya no hace falta aprovisionar nada para llamar**. Si su cuenta no tiene número propio, la llamada sale con un número de Asixto; cuando le asignemos uno suyo, sale con ese sin que cambie la petición ([página 15](./15-api-reference.md)). El catálogo de errores pasa de 30 códigos a 29. **(3)** La [página 13](./13-data.md) declara el **tipo** de proveedor que recibe cada contenido en vez de su nombre; la lista nominal se entrega bajo acuerdo al contacto técnico que la pida. **(4)** El informe de certificación distingue cuatro resultados por comprobación en vez de tres: se separa «no pudimos medirla» de «todavía no automatizada», que son las que revisamos con su equipo y no cambian por repetir la corrida. La [página 08](./08-certification.md) dice fila por fila cuál es cuál, y un aviso ya no se presenta como un fallo. **(5)** **Publicar el catálogo es un acto propio**, distinto de certificar: hasta que lo ordena, un servidor registrado y aprobado no aporta ninguna herramienta al agente. Es la fase 3 de la [página 08](./08-certification.md) y se hace desde la aplicación. Ninguno de los cinco exige cambios en un servidor ya construido. |
| 2026-09-09 | v1.0 | **Incompatible.** Se declaran juntos los **tres cambios que ya estaban en vigor** y que rompen un supuesto razonable de quien ya integró. **(1)** El entorno de pruebas **cobra, y al mismo precio**: `ak_test_` ejecuta de verdad y consume créditos como `ak_live_`. **(2)** La llamada de `ak_test_` sale **siempre** con un número de Asixto —en pruebas el número propio del tenant no se mira—, y el error `telephony_not_ready` queda **retirado**: retirar un código del catálogo es, por la política de esta página, un cambio incompatible, así que se reclasifica lo anunciado el 2026-09-08. **(3)** Leer el stream del turno lo autoriza **`jobs:read`**, no el scope con el que se abrió, así que una clave emitida antes de este cambio puede pagar un turno y no poder leerlo; toda clave nueva nace con ese scope y **no existe ningún `agent:read`**. Qué hacer en cada caso: [Tres cambios incompatibles ya en vigor](#tres-cambios-incompatibles-ya-en-vigor). |
| 2026-09-09 | v1.0 | **Compatible.** La [página 11](./11-webhooks.md) publica la **forma de `data` campo por campo** para los once eventos del catálogo, que era la mitad del sobre que faltaba: ya puede tipar y probar su receptor sin esperar a recibir una entrega. Con dos avisos que rompen un receptor descuidado: `job.expired` **no trae `result`**, y los tres eventos de saldo son el único caso en el que **una clave puede faltar** en vez de llegar en `null`. |
| 2026-09-10 | v1.0 | **Compatible.** La **era de protocolo se comprueba al dar de alta el servidor**, no solo al certificar: leemos el catálogo de su entorno de pruebas y, si anuncia otra revisión que la declarada, el registro se rechaza en el momento indicándole la que observamos. Solo se rechaza cuando la desalineación **se observa**: si su entorno de pruebas todavía no responde, o la lectura falla por otro motivo, el alta se completa igual y la era se comprueba al certificar. No exige cambios en un servidor ya construido, y un servidor ya registrado no se toca: lo que evita es gastar una corrida de certificación en un dato que ya se podía cotejar ([fase 1 de la página 08](./08-certification.md)). |
| 2026-09-11 | v1.0 | **Compatible.** Su agente admite un **segundo tipo de regla**, que se configura en la aplicación y no cambia nada de su servidor. Las que ya tenía son **deterministas**: un disparador cerrado —palabra clave, emoción, intentos— ejecuta una herramienta sin que el modelo decida. Las nuevas son **por instrucción**: usted redacta la condición con sus palabras y el agente decide cuándo aplica; si les asocia una herramienta, se le ordena ejecutarla al cumplirse, y solo puede ser una de su catálogo certificado. No son deterministas: son una instrucción con prioridad alta, no un `if`. Y puede crear **sus propias carpetas** para agruparlas: varias reglas en la misma carpeta, en orden, describen un flujo por pasos. Eliminar una carpeta **no** borra sus reglas. |
| 2026-09-11 | v1.0 | **Compatible.** Se publica el **análisis de video**, `POST /v1/video/analysis`, que la API ya atendía y solo se podía averiguar preguntando: se manda la URL del video que usted aloja, lo autoriza el permiso `video:analyze` y se factura con el código `video_analysis`. La tarifa es **plana, 270 créditos por video**, así que uno de diez segundos y uno de cinco minutos cuestan lo mismo ([página 12](./12-pricing.md)); por eso su entrada tiene dos techos —50 MiB y 600 segundos— y pasarlos **rechaza la petición nombrando el techo** en vez de analizar parte del video. Campos, límites y ejemplos ejecutables en la [página 15](./15-api-reference.md). No exige cambios en una integración ya construida: una clave emitida sin `video:analyze` sigue funcionando igual para todo lo demás, y para llamar a esta operación emita otra que lo lleve. |
| 2026-09-11 | v1.0 | **Compatible.** La [página 13](./13-data.md) publica **cuánto vive cada dato, con cifra y separado por propósito**, que era la mitad que faltaba de la retención: la entrada no se persiste, el resultado y la ventana de conversación viven **24 h**, el **registro de uso 400 días** y el **asiento contable el plazo legal**. Los 400 días son fijos para todas las cuentas y cubren un año cerrado más el margen para reclamar el último periodo; dentro de la ventana puede consultar el detalle petición a petición, y Asixto se reserva servir solo el **resumen** de los tramos más antiguos. Si necesita historia más larga, expórtela: el resumen de uso y los webhooks son continuos. La misma página publica ahora las cifras de la **baja de la cuenta** —la pide el Propietario, hay **30 días de gracia** en los que se cancela, y al ejecutarse se borra todo lo del Gateway salvo los registros de facturación, que la ley obliga a conservar anonimizados— y declara que **la grabación de la llamada no existe**: Asixto no almacena el audio de una llamada de `/v1/calls`. |
| 2026-09-11 | v1.0 | **Incompatible.** El **tope de gasto del excedente pasa a tener un techo del proveedor**: el efectivo es el menor entre el que usted fija y el que Asixto financia, y **dejarlo sin fijar ya no significa «ilimitado»** —resuelve al techo—. El techo empieza en **20 USD** y sube con el **historial de pago** (3 ciclos pagados a tiempo ⇒ 100 USD; 6 ⇒ 500 USD, que es el máximo absoluto), nunca con el consumo. Una factura vencida o devuelta lo devuelve al primer escalón. Qué cambia en la práctica: una operación puede responder `402 spending_cap_reached` donde antes no lo hacía, y el aviso `credits.spending_cap_reached` se levanta una vez por periodo. **Medido antes de publicarlo: cero cuentas afectadas hoy** —ninguna tiene el excedente encendido—, y se declara igual porque cambia un supuesto razonable de quien ya integró. Recargar saldo no levanta un tope: eso sigue igual ([página 12](./12-pricing.md)). |
| 2026-09-21 | v1.0 | **Incompatible.** Dos comprobaciones nuevas en la certificación y una severidad corregida, las tres sobre la **descripción y la forma** de sus herramientas. **(1)** `readOnlyHint` era ya obligatorio para publicar —una herramienta sin él no entra en el catálogo, porque no podemos saber si escribe— pero el verificador público solo lo **avisaba**: leía «0 bloqueantes» y perdía la herramienta al publicar. Ahora es **bloqueante en los dos sitios**, y la [página 02](./02-tool-contract.md) deja de decir que su ausencia se resuelve por omisión. **(2)** Nace **«Descripciones sin inyección»**, bloqueante: una descripción que intenta gobernar al modelo («ignora las instrucciones anteriores», `system prompt`, pedirle que no use otras herramientas, marcas de rol) o que trae **caracteres invisibles** ya no pasa. Su descripción llega a nuestro modelo en el mismo contexto que las instrucciones de la empresa, así que esto no es estilo; lo que sí es estilo —«llama siempre a esta herramienta»— sigue siendo aviso. **(3)** «Una operación por herramienta» pasa a **refutarse sola** en el caso concluyente: un parámetro cuyo `enum` deja elegir entre un método HTTP de lectura y uno que escribe. Publique una herramienta de lectura y una por cada acción que escribe. Y una tercera fila, solo **aviso**: le decimos si su URL de pruebas está en otro dominio que su producción, con los dos hosts, para que compruebe que apunta a su servidor MCP y no a otro servicio suyo. Todo lo demás del contrato queda igual ([página 08](./08-certification.md)). |
| 2026-09-16 | v1.0 | **Incompatible.** La **era de protocolo deja de pedirse: la detectamos nosotros.** El campo desaparece del alta —si su cliente llama a la API de la consola con él, quítelo— y en su lugar el registro adquiere una **precondición: su entorno de pruebas tiene que estar desplegado y respondiendo en el momento de dar de alta**, porque hablamos con él para leer cómo acuerda la revisión. Antes se declaraba y se cotejaba con lo observado; eso le hacía adivinar un dato técnico que nosotros ya leemos del cable, y dejaba pasar el alta con la era **declarada y sin comprobar** cuando su servidor no respondía —un estado que nadie sabía interpretar—. Ahora hay tres desenlaces y los tres se dicen con nombre: la detectamos y su servidor queda registrado con la era **ya comprobada**; no acordamos revisión con su entorno de pruebas y el alta se **rechaza** diciéndole qué anunció y qué desplegar; o falla **nuestro** lado, y entonces lo decimos con esas palabras y basta reintentar en unos minutos. Un servidor ya registrado **no se toca**. Sigue siendo cierto que las dos eras se admiten y que migrar es opcional ([fase 1 de la página 08](./08-certification.md)). |
| 2026-10-09 | v1.0 | **Incompatible.** Cuatro cambios para que la primera llamada de una integración salga, aplicados sin periodo de convivencia con el acuerdo de la única cuenta integrada. **(1)** Con `ak_test_` solo se llama a un número de prueba **verificado** de la cuenta, hasta **5** por cuenta entre pendientes y verificados; si no, `409 test_destination_not_verified`. **(2)** Con `ak_live_` el número propio es **obligatorio**: sin él listo, `409 phone_number_required`, `409 phone_number_in_review` o `402 phone_number_suspended`, y ya no sale un número de Asixto de reserva. **(3)** El excedente exige un **medio de pago verificado**; sin él, el saldo que no alcanza responde `402 insufficient_credits`. **(4)** Se retira la baranda que publicaban las páginas 09, 16 y 18: el entorno de pruebas ya no tiene techo de créditos propio. Qué hacer en cada caso: [Cuatro cambios incompatibles](#cuatro-cambios-incompatibles-para-que-la-primera-llamada-salga). |
| 2026-10-09 | v1.0 | **Compatible.** **(1)** El cupo con el que nace una cuenta sube a **10 peticiones y 5 llamadas** simultáneas por entorno, **sin techo** de créditos por periodo ([página 18](./18-api-limits.md)). **(2)** Si a una cuenta se le fija un techo y la operación no cabe ni con el periodo vacío, responde `409 request_exceeds_period_quota`, sin `Retry-After`. **(3)** El acuse de `POST /v1/calls` puede traer `warnings.transfer_disabled` cuando la transferencia de pruebas sale apagada, y el replay de una `Idempotency-Key` devuelve los mismos `warnings` que el acuse original ([página 15](./15-api-reference.md)). **(4)** Los rechazos por destino de prueba o por número propio no abren trabajo: no se cobran y no consumen la clave. **(5)** La bienvenida de **3.000 créditos** se acredita sola, una vez por cuenta, cuando tiene el módulo y un medio de pago verificado ([página 12](./12-pricing.md)). **(6)** La fase 4 de la certificación pasa a ser el Playground y las llamadas a sus números de prueba ([página 08](./08-certification.md)). |

---

<!-- fuente: https://gateway.asixto.com/docs/10-operation.md -->

# 10 · Operación y soporte

Esta página responde lo que aparece cuando el servidor ya está escrito: reintentos, concurrencia,
qué pasa si su servidor se cae, cómo prueba con un agente real, dónde ve las trazas y a quién
escribe. Es la parte del contrato que no se ve en el código pero se nota en producción.

## Reintentos y concurrencia

| Situación | Qué hace el Gateway |
|---|---|
| Conexión cortada antes de recibir la respuesta | Reintenta **una vez**, con la misma clave de idempotencia y un `requestId` nuevo. |
| Tiempo de espera agotado (30 s) | **No reintenta.** Informa al agente de un fallo transitorio y la conversación continúa sin la herramienta. |
| `error_type: "SYSTEM"` | No reintenta por su cuenta. El agente puede volver a intentarlo si el usuario insiste, y esa será una llamada nueva con clave nueva. |
| Cualquier otro `error_type` | No reintenta: son respuestas de negocio, no fallos. |
| Bucle del modelo (misma llamada, mismos argumentos, en el mismo turno) | Se descarta antes de salir a su servidor. |

**Concurrencia.** Una conversación es secuencial: no habrá dos llamadas simultáneas del mismo
diálogo. Lo que sí ocurre en paralelo son **conversaciones distintas del mismo tenant**, y en horas
punta pueden ser decenas. Dimensione por conversaciones concurrentes, no por usuarios.

**Clave de idempotencia en vuelo.** Si le llega una clave que **todavía está ejecutándose**, no
espere ni duplique: responda `error_type: "SYSTEM"` con un mensaje neutro. El agente lo trata como
fallo transitorio, y el resultado de la primera ejecución queda registrado bajo esa clave para
cuando se consulte de nuevo.

## Si su servidor deja de responder

| Momento | Qué pasa |
|---|---|
| Un fallo aislado | El agente responde sin ese dato. La conversación no se cae. |
| Fallos repetidos en pocos minutos | El Gateway deja de ofrecer esa herramienta durante unos minutos y la reintenta después. El agente no promete lo que no puede hacer. |
| Caída sostenida | La capacidad se desactiva y se avisa al contacto técnico que usted registró. Se reactiva sola cuando su servidor vuelve a responder correctamente. |

Nada de esto interrumpe una llamada telefónica en curso: el agente sigue conversando con lo que
tiene.

## Entorno de pruebas

Se da de alta junto con la URL y la credencial ([README](./README.md)) y es donde corre la
certificación, porque hay comprobaciones que escriben.

| Requisito | Detalle |
|---|---|
| Credencial propia | **DEBE** ser distinta de la de producción. |
| Mismo catálogo | **DEBE** exponer las mismas herramientas con los mismos esquemas. Si divergen, la certificación no dice nada útil sobre producción. |
| Datos semilla | **DEBERÍA** tener, al menos: un cliente localizable, un cliente que no le pertenezca al primero, una entidad en estado terminal (una cita cancelada, por ejemplo) y un catálogo con al menos dos ítems. Sin eso no se pueden probar propiedad ni estado terminal. |
| Aislamiento | **DEBE** no enviar correos, mensajes ni cobros reales. |

## Pruebas con un agente real

Con el catálogo publicado, pruebe a su agente con una clave `ak_test_` por dos vías: por chat, en el
**Playground** de la consola, y por voz, con `POST /v1/calls` a uno de sus **números de prueba
verificados** —hasta 5 por cuenta, que da de alta y verifica en la consola ([página 14](./14-api-quickstart.md))—.
La llamada de pruebas sale con un número de Asixto y no puede ir a ningún otro teléfono. Las dos vías se
cobran como cualquier consumo ([página 12](./12-pricing.md)). Sirve para ver lo que ninguna batería
detecta: si el agente entiende sus descripciones, si elige bien entre dos herramientas parecidas y si sus
respuestas se leen bien en voz alta.

Recomendación: lleve un guion de cinco o seis conversaciones reales de su operación y contraste lo
que el agente hace con lo que haría un asesor.

## Trazas y diagnóstico

- Registre por invocación el `com.asixto/requestId` que recibe. Es la referencia común: con ese
  identificador Asixto puede localizar la conversación, y usted su ejecución.
- El informe de certificación queda en su panel, con el resultado por comprobación y la fecha de la
  última ejecución.
- Los tiempos que Asixto mide son de extremo a extremo, así que pueden ser algo mayores que los
  suyos: incluyen red y encolado.

## Versionado de su servidor

Declare una versión en la identidad de su servidor y súbala cuando cambie el catálogo. No dispara
nada automático, pero aparece en el informe y hace que un incidente se pueda fechar.

Lo que sí obliga a recertificar está en la [página 08](./08-certification.md): herramienta nueva,
cambio de esquema, renombrado, cambio de credencial o de URL.

## Datos, retención e idioma

| Tema | Cómo funciona |
|---|---|
| Qué guarda Asixto de sus respuestas | El texto que entra a la conversación, como parte del historial de esa conversación. No se replica su base de datos ni se construye un catálogo paralelo. |
| Retención | Con cifra y por tipo de dato en la [página 13](./13-data.md): la entrada no se persiste, el resultado vive 24 h y la ventana de la sesión 24 h de inactividad. Ni los argumentos que le enviamos ni lo que usted devuelve se copian a otro almacén. |
| Datos personales | Devuelva el mínimo necesario ([garantía 5](./05-guarantees.md)). Lo que no envía, no se almacena. |
| Idioma | El `user_message` y los textos legibles, en el idioma que llega en `com.asixto/locale` ([página 02](./02-tool-contract.md)). Si su servidor atiende un solo idioma, decláreselo en la incorporación y el tenant se configura en consecuencia. |
| Media de entrada | Si el cliente final envía una foto o un documento, **su herramienta no lo recibe**: el agente lo procesa y, si es relevante, lo describe en los argumentos de texto. |

## Contacto

| Para | Canal |
|---|---|
| Dudas de interpretación del contrato, escenarios nuevos, borrado, interruptores por acción, multi-idioma | El contacto técnico que le asigna Asixto en la incorporación |
| Incidentes en producción | El mismo canal, citando el `requestId` y la hora aproximada |
| Cambios que afectan al contrato | Se anuncian con antelación según la [página 09](./09-changelog.md) |

Cuando esta documentación dice «escálelo», se refiere a ese contacto técnico. Ninguna de esas
decisiones se resuelve improvisando en el código: casi todas afectan a los tres repositorios de
Asixto y se coordinan.

---

<!-- fuente: https://gateway.asixto.com/docs/11-webhooks.md -->

# 11 · Webhooks

Cómo recibe su sistema los eventos que Asixto le entrega: la forma del sobre, la verificación de la
firma, la deduplicación y qué pasa cuando su receptor no responde.

Es la primera página de la **etapa 2** ([README](./README.md)): aquí la dirección se invierte y el
que expone un endpoint HTTP es usted. No depende de la etapa 1 y no hace falta tener un servidor MCP
para implementarla.

> **Disponibilidad.** Lo que esta página publica es el **contrato de la forma**, y está fijado: la
> cabecera de firma, el mensaje que se firma, la tolerancia, el sobre, los nombres de los eventos, la
> forma de su `data` y la escalera de reintentos. Sirve para escribir y probar su receptor hoy, con sus propios datos de
> prueba. Lo que **todavía no está abierto** es la otra mitad: el alta del endpoint y la
> reproducción manual desde la aplicación de Asixto, y la emisión de los eventos. Hasta que se
> anuncie ([página 09](./09-changelog.md)), un receptor recién montado no va a recibir nada — y no es
> un fallo suyo. Escríbalo ahora si quiere: cuando se abra, esta página no cambia.

---

## 1 · Lo que llega

Un `POST` con `content-type: application/json` a la URL que usted registre, con el sobre completo en
el cuerpo.

```json
{
  "id": "9f1c3a70-4e0b-4f2a-9d5e-2b8c1a6f0d33",
  "type": "job.succeeded",
  "created": 1787923200,
  "environment": "live",
  "data": {}
}
```

| Campo | Qué es |
|---|---|
| `id` | Identificador de **esta entrega**. Es su clave de deduplicación (sección 4). Es **opaco**: no dependa de su forma ni la parsee. |
| `type` | Evento del catálogo (sección 5), en `recurso.estado` y siempre en minúsculas. |
| `created` | Alta del evento en segundos unix. **No cambia entre reintentos**: no es la hora del intento. |
| `environment` | `live` o `test`. Cada endpoint se registra para un entorno y solo recibe el suyo. |
| `data` | El contenido del evento. **Su forma depende del `type`** y está publicada campo por campo en la sección 5, en [La forma de `data`](#la-forma-de-data-evento-por-evento). Los cinco campos de arriba son los mismos para todos los eventos, y son los que su capa de transporte necesita. |
| `replay_of` | **Solo en una reproducción manual** (sección 6). En una entrega normal el campo no viene: no llega como `null`, no llega. |

El sobre **no lleva versión de la superficie**. Hoy hay una sola y un campo con un único valor
posible no le permitiría decidir nada; el día que haya una segunda, la que gobierna el sobre será la
del endpoint y se anunciará como cambio de contrato ([página 09](./09-changelog.md)).

No añada suposiciones sobre campos que no están en esta tabla: lo que no aparece aquí, no forma
parte del contrato.

---

## 2 · Verificar la firma

**DEBE** verificar toda entrega antes de procesarla. Su endpoint está expuesto a internet y el sobre
dispara trabajo en su sistema: sin firma, cualquiera que conozca la URL puede decirle que un trabajo
terminó bien.

Cada entrega lleva una cabecera:

```http
Asixto-Signature: t=1787923200,v1=8f2a…c1
```

| Parte | Qué es |
|---|---|
| `t` | El instante de la firma, en segundos unix. |
| `v1` | HMAC-SHA256 en hexadecimal del mensaje canónico, con el secreto de firma del endpoint como clave. Puede venir **más de uno** (sección 3). |

El mensaje que se firma es el instante, un punto y el cuerpo **exacto** que se transmitió:

```
{t}.{cuerpo crudo}
```

Cuatro reglas que deciden si su verificador funciona:

1. **Firme sobre el cuerpo crudo, no sobre el JSON re-serializado.** Si su framework parsea el
   cuerpo y usted lo vuelve a serializar para verificar, obtiene otros bytes y **ninguna entrega
   válida le va a coincidir**. Es el error número uno. En Express se resuelve con
   `express.raw({ type: 'application/json' })` en esa ruta; en FastAPI, con `await request.body()`.
2. **Use el texto de `t` tal como llega**, sin convertirlo a número y volver a formatearlo. El
   mensaje se compone con esa cadena.
3. **Compare en tiempo constante** (`hmac.compare_digest`, `crypto.timingSafeEqual`). Con `==`, el
   tiempo de respuesta revela cuántos caracteres del hexadecimal acertó quien lo intente, y eso
   convierte falsificar la firma en una búsqueda byte a byte.
4. **Recorra todas las firmas `v1=`** y acepte si coincide **cualquiera**. Comparar solo con la
   primera funciona hoy y le rompe el día que rote el secreto (sección 3).

La cabecera viaja **una sola vez con varias firmas dentro**, nunca repetida. Si su pila le entrega
una lista de valores para `Asixto-Signature`, rechace la entrega en vez de unirlos: una cabecera
repetida no es nuestra.

### Python

```python
import hashlib
import hmac
import time

TOLERANCIA_S = 300


def verificar(cabecera: str | None, cuerpo: bytes, secretos: list[str]) -> bool:
    """`cuerpo` es el cuerpo CRUDO. `secretos` son los que su receptor conoce (uno, o dos al rotar)."""
    if not cabecera:
        return False

    instante: str | None = None
    firmas: list[str] = []
    for parte in cabecera.split(","):
        clave, _, valor = parte.strip().partition("=")
        if clave == "t":
            instante = valor
        elif clave == "v1":
            firmas.append(valor)

    if instante is None or not instante.isdigit() or not firmas:
        return False

    # Ventana SIMÉTRICA: un instante en el futuro también se rechaza.
    if abs(int(time.time()) - int(instante)) > TOLERANCIA_S:
        return False

    mensaje = instante.encode() + b"." + cuerpo
    for secreto in secretos:
        esperada = hmac.new(secreto.encode(), mensaje, hashlib.sha256).hexdigest()
        if any(hmac.compare_digest(firma, esperada) for firma in firmas):
            return True
    return False
```

### TypeScript

```ts
import { createHmac, timingSafeEqual } from 'node:crypto';

const TOLERANCIA_S = 300;

const igualSegura = (a: string, b: string): boolean => {
  const x = Buffer.from(a, 'utf8');
  const y = Buffer.from(b, 'utf8');
  // La longitud se compara antes: `timingSafeEqual` exige buffers del mismo tamaño.
  return x.length === y.length && timingSafeEqual(x, y);
};

export const verificar = (cabecera: string | undefined, cuerpo: string, secretos: string[]): boolean => {
  if (!cabecera) return false;

  let instante: string | null = null;
  const firmas: string[] = [];

  for (const parte of cabecera.split(',')) {
    const corte = parte.indexOf('=');
    if (corte <= 0) continue;
    const clave = parte.slice(0, corte).trim();
    const valor = parte.slice(corte + 1).trim();
    if (clave === 't') instante = valor;
    if (clave === 'v1' && valor) firmas.push(valor);
  }

  if (instante === null || !/^[1-9][0-9]*$/.test(instante) || firmas.length === 0) return false;

  const ahora = Math.floor(Date.now() / 1000);
  if (Math.abs(ahora - Number(instante)) > TOLERANCIA_S) return false;

  return secretos.some((secreto) => {
    const esperada = createHmac('sha256', secreto).update(`${instante}.${cuerpo}`, 'utf8').digest('hex');
    return firmas.some((firma) => igualSegura(firma, esperada));
  });
};
```

### Qué devolver

| Situación | Su respuesta |
|---|---|
| Firma válida y evento procesado o encolado | **2xx**. Es el único acuse (sección 7). |
| Firma inválida, cabecera ausente o malformada | **400**, y regístrelo. No procese nada. |
| Instante fuera de la ventana | **400**. Es un reenvío o un reloj desajustado, no un evento nuevo. |
| Válida, pero su sistema no puede procesarla ahora | Cualquier código que no sea 2xx: se reintenta (sección 7). Mejor todavía: acuse y procese en su cola. |

---

## 3 · La ventana anti-replay: 300 s, y es simétrica

**La ventana la aplica usted, no nosotros.** Lo único que hace Asixto es firmar un instante; quien
rechaza una entrega capturada y reenviada mañana es su receptor, comparando ese instante con su
reloj. Por eso la cifra es parte del contrato público y no una preferencia:

| Regla | Valor |
|---|---|
| Tolerancia | **300 segundos** |
| Cómo se compara | Sobre la diferencia **absoluta** entre `t` y su reloj |

Son los mismos 300 s que trae por omisión el SDK de Stripe, así que si su equipo ya verifica webhooks
de Stripe no tiene dos ventanas que razonar.

**Lo que sí es distinto: la nuestra es simétrica.** La comprobación habitual (`ahora - t > tolerancia`)
deja pasar cualquier instante en el **futuro**, sin límite. Los relojes derivan en las dos
direcciones y en su entorno no hay NTP garantizado, así que una ventana de un solo lado no acota nada
por arriba. Compare el valor absoluto, como en los ejemplos de la sección 2.

**Si falta:** una entrega capturada —en un log, en un proxy, en una traza— se puede reenviar semanas
después con su firma intacta, y su sistema la aceptará como nueva.

### Rotación del secreto: varias firmas en la misma cabecera

El secreto de firma se rota **sin ventana de caída**, y eso tiene una consecuencia directa en su
código: durante la rotación la cabecera lleva **una firma `v1=` por cada secreto activo**, y basta
que coincida una.

| Paso | Quién | Qué pasa |
|---|---|---|
| 1 · Emitir | Usted, desde la aplicación de Asixto | Se emite un secreto nuevo. **El anterior sigue activo y sigue firmando.** El nuevo se muestra **una sola vez**: no hay ninguna lectura que lo devuelva después. |
| 2 · Desplegar | Usted | Su receptor conoce **los dos** y acepta cualquiera de los dos. |
| 3 · Revocar | Usted | Revoca el viejo. Un endpoint no se queda sin secreto: revocar el último se rechaza. |

Hay sitio para **dos secretos activos** por endpoint, que es lo que hace posible el orden
desplegar-luego-revocar. Un receptor que compare solo con la primera firma queda fuera del contrato
y pierde entregas justo en el paso 2, que es cuando menos lo espera.

El secreto se reconoce por su prefijo `whsec_`, para que quien lo vea en un `.env` o en un log sepa
qué es y un escáner de secretos reconozca el patrón. Trátelo como credencial, no como configuración.

---

## 4 · Deduplicación: `id`, durante 7 días

El mismo evento **puede llegarle más de una vez**. Es la consecuencia inevitable del acuse: si su
`2xx` se pierde en la red, para nosotros ese intento falló y se reintenta.

| Regla | Valor |
|---|---|
| Clave de deduplicación | El campo `id` del sobre |
| Ventana que garantiza Asixto | **7 días**: un mismo `id` no se le entrega dos veces al mismo endpoint dentro de ese plazo |
| Lo que se le pide a su almacén | Registrar el `id` con un TTL de **al menos 7 días** |

Es la misma cifra que ya se le exige en la [garantía 1](./05-guarantees.md) para la idempotencia de
las herramientas, y a propósito: la deduplicación es simétrica —usted descarta por `id`, nosotros no
reemitimos— y dos ventanas distintas dejarían un tramo en el que uno de los dos deja de cumplir sin
que el otro lo sepa.

**Si falta:** un trabajo se procesa dos veces en su sistema porque un acuse se perdió en la red. No
es un caso raro ni hipotético: es el comportamiento normal de cualquier cola de entregas.

### Orden

No programe contra el orden de llegada. Un reintento puede llegar **después** de un evento
posterior, y eso no es un fallo. Use `created` y el estado de su propio sistema para decidir.

Lo que sí se sostiene: a un mismo receptor no se le envían dos entregas a la vez de la misma
conversación. Entre conversaciones distintas van en paralelo.

---

## 5 · Catálogo de eventos

Se suscribe por evento al registrar el endpoint. **El catálogo es cerrado**: un nombre que no esté en
esta tabla se rechaza al dar de alta y al reconfigurar, no al entregar. Si necesita un evento que no
está, escálelo ([página 10](./10-operation.md)) en vez de inventar el nombre.

| Evento | Significado |
|---|---|
| `job.succeeded` | El trabajo terminó bien. `data` lleva el resultado publicado y el consumo. |
| `job.failed` | El trabajo falló. `data` lleva el código de error del contrato, no una cadena libre. |
| `job.expired` | El trabajo venció su plazo sin cerrarse y su reserva de créditos se liberó. |
| `credits.threshold_reached` | El consumo de la empresa cruzó un umbral de aviso de su saldo. `data` lleva el porcentaje alcanzado. |
| `credits.exhausted` | La empresa agotó su saldo: las operaciones responderán `402` hasta que recargue. |
| `credits.spending_cap_reached` | El tope de gasto de su consumo adicional no admite más consumo: las operaciones responderán `402 spending_cap_reached` hasta que lo aumente. **Recargar saldo no lo levanta** —hay que subir el tope—, y eso es lo que lo separa de `credits.exhausted` ([página 16](./16-api-errors.md)). |
| `certification.finished` | Terminó una corrida de certificación de su servidor MCP. `data` lleva el veredicto y las comprobaciones bloqueantes que fallaron, que es lo que decide si sus herramientas salen a producción ([página 08](./08-certification.md)). |
| `webhook_endpoint.disabled` | Desactivamos uno de sus endpoints por fallos sostenidos (sección 8). Lo reciben sus **otros** endpoints activos, que es lo único que puede recibir el aviso de que uno dejó de recibir. |
| `tool.disabled` | La vigilancia dejó de ofrecer al agente las herramientas de una capacidad de su servidor MCP, por caída sostenida. `data` lleva la capacidad, las herramientas retiradas y los fallos consecutivos observados. **Se reactiva sola** cuando su servidor vuelve a responder: no hay evento de vuelta ni hace falta que nos escriba. |
| `conversation.handoff_requested` | El agente pidió pasar la conversación a una persona. `data` lleva `job_id`, `conversation_id`, `reason` (el motivo que dio el agente, recortado) y `requested_at`. Es la vía **fina**: la misma señal viaja dentro del resultado del trabajo, así que si ya recibe `job.succeeded` la tiene sin suscribirse a este evento. |

Dos precisiones que evitan un diagnóstico equivocado:

- **`job.expired` no es `job.failed`.** El trabajo no falló: terminó sin completarse, y su
  reacción es distinta (volver a pedirlo, no diagnosticar un error).
- **Los dos umbrales de saldo caen en el mismo evento.** El porcentaje es un dato de `data`, no un
  tipo de evento: con un evento por umbral, cambiar un umbral sería un cambio incompatible del
  contrato. Y el saldo es el de **su empresa**, el mismo que consume el resto de la plataforma: un
  aviso puede nacer de consumo que no entró por la API del Gateway.

### La forma de `data`, evento por evento

`data` es lo único del sobre que cambia con el `type`. Aquí está campo por campo, así que puede tipar su
receptor y probarlo hoy, sin esperar a recibir una entrega.

Tres reglas que valen para todo el catálogo:

- Los nombres van en **`snake_case`**, igual que el resto de la superficie.
- **Un campo sin valor viaja en `null`, no se omite.** La única excepción son los tres eventos de saldo, y
  está dicha en su tabla.
- **`data` no lleva identificadores internos nuestros**, y la lista de cada evento es cerrada: un campo que
  no esté en estas tablas no forma parte del contrato.
- La única clave que trae contenido SUYO es `metadata`, y solo en los eventos de trabajo: es lo que mandó en
  la petición ([página 15](./15-api-reference.md)), devuelto tal cual.

#### `job.succeeded` y `job.failed`

Los dos comparten forma. Lo que los distingue es el valor de `status`, no la lista de campos: el cierre
publica el estado guardado del trabajo.

| Campo | Tipo | Qué es |
|---|---|---|
| `job_id` | texto | El trabajo que cerró. El mismo que le devolvió el `202` |
| `operation` | texto | La operación que lo originó, con el mismo código que sirve `GET /v1/jobs/{job_id}` ([página 17](./17-jobs.md)) |
| `status` | texto | `succeeded` o `failed`. Es la segunda mitad del `type` y el mismo vocabulario de la [página 17](./17-jobs.md) |
| `credits_charged` | entero o `null` | Créditos **liquidados**, no reservados: es la cifra que concilia. `null` si el trabajo no llegó a liquidar |
| `error_code` | texto o `null` | En `job.failed`, el código del contrato ([página 16](./16-api-errors.md)), no una cadena libre. `null` cuando no hubo error |
| `result` | objeto o `null` | El resultado publicado de la operación, el mismo que sirve `GET /v1/jobs/{job_id}`. `null` si no hay |
| `metadata` | objeto | Su `metadata` de la petición, tal cual. **Ausente** si no mandó ninguno: es la excepción a la regla del `null`, porque una clave vacía no se distingue de un dato que se perdió |
| `metadata_omitted` | `true` | Solo si su `metadata` no cupo en el cuerpo. Ver abajo |

#### `job.expired`

La misma forma **menos `result`**: un trabajo que venció no produjo ninguno, y una clave en `null` le
invitaría a buscar algo que no existe.

| Campo | Qué trae |
|---|---|
| `job_id` · `operation` | Como arriba |
| `status` | Siempre `expired` |
| `credits_charged` | **`0` casi siempre**, porque vencer libera la reserva; y se declara el cero en vez de omitirlo, que se leería como «no se sabe». Lo consultamos al cerrar en vez de darlo por hecho: si una liquidación llegó a cobrar y no logró escribir su cierre, aquí viene **el importe que se le cobró de verdad**, y ese es el que concilia |
| `error_code` | Siempre `job_not_completed` |
| `metadata` | Su `metadata` de la petición, con la misma regla de arriba. Viaja también aquí a propósito: un trabajo que venció es el caso en el que más falta le hace cuadrarlo con su expediente |

**Si su `metadata` no cupiera**, la entrega sale **sin él** y con `metadata_omitted: true` en `data`. Ocurre
solo si el resultado del trabajo ya llena por sí solo los 256 KB del cuerpo, porque su `metadata` está
acotado en 4 KB en la puerta. Lo que se cae es el dato de conciliación, nunca el aviso del cierre: perder el
aviso de un trabajo ya cobrado sería peor, y usted siempre puede releer el resultado con
`GET /v1/jobs/{job_id}`.

#### `credits.threshold_reached`, `credits.exhausted`, `credits.spending_cap_reached`

Los tres llevan **solo cifras**, y son el único caso del catálogo en el que **una clave puede faltar**: viaja
la que el aviso traía medida. No llega en `null`; si no está, no se midió.

| Campo | Tipo | Qué es |
|---|---|---|
| `usage_percent` | número | Porcentaje del saldo consumido con el que se cruzó el umbral |
| `used_credits` | número | Créditos consumidos en el periodo |
| `total_available` | número | Créditos disponibles del periodo |
| `overage_credits` | número | Consumo adicional acumulado |
| `overage_spending_cap` | número | El tope de gasto vigente de ese consumo adicional |

**No lleva qué umbral se cruzó**, y es deliberado: el dato es `usage_percent`, así que mover un umbral no
rompe su código. Tampoco lleva ningún identificador de su balance, que es nuestro.

#### `certification.finished`

| Campo | Tipo | Qué es |
|---|---|---|
| `run_id` | texto | La corrida. Es lo que cita si escala el resultado |
| `server` | texto | Su servidor MCP, con el identificador que usted le puso al darlo de alta |
| `status` | texto | `passed`, `failed` o `inconclusive`. El tercero significa «no lo pudimos comprobar», no «su servidor falló» ([página 08](./08-certification.md)) |
| `failed_blocking_checks` | lista de texto | Las comprobaciones **bloqueantes** que fallaron, nombradas como las filas de la [página 08](./08-certification.md) y no con códigos internos. Vacía si no falló ninguna |
| `suspended` | booleano | `true` si esa corrida retiró herramientas que ya estaban publicadas, por una regresión |
| `retired_tools` | entero | Cuántas se retiraron. `0` cuando `suspended` es `false` |

Llega al endpoint de **`test`**: la batería nunca toca su producción.

#### `webhook_endpoint.disabled`

| Campo | Tipo | Qué es |
|---|---|---|
| `endpoint_id` | texto | El endpoint que se desactivó. **No es el que recibe esta entrega** (sección 8) |
| `url` | texto | El destino que dejó de recibir |
| `reason` | texto | Siempre `sustained_failures` |
| `consecutive_failures` | entero | Entregas agotadas desde su último éxito |
| `last_success_at` | texto o `null` | La última entrega exitosa, en ISO 8601. `null` si nunca hubo ninguna |

#### `tool.disabled`

| Campo | Tipo | Qué es |
|---|---|---|
| `server` | texto | Su servidor MCP, con el mismo identificador |
| `capability` | texto | La capacidad cuyas herramientas dejaron de ofrecerse ([página 04](./04-capabilities.md)) |
| `tools` | lista de texto | Las herramientas retiradas, con el nombre con el que se expusieron al agente |
| `reason` | texto | Siempre `sustained_outage` |
| `consecutive_failures` | entero | Fallos consecutivos observados contra su servidor |
| `last_success_at` | texto o `null` | La última invocación que respondió, en ISO 8601. `null` si nunca respondió |

Llega al endpoint de **`live`**: la vigilancia mide las llamadas reales.

#### `conversation.handoff_requested`

| Campo | Tipo | Qué es |
|---|---|---|
| `job_id` | texto | El turno en el que el agente pidió el relevo |
| `conversation_id` | texto o `null` | **Su** identificador de conversación, el que envió en la petición. `null` si el turno no llevaba ninguno |
| `reason` | texto o `null` | El motivo que dio el agente, recortado a **512 caracteres**. `null` si no dio ninguno |
| `requested_at` | texto | Cuándo se pidió el relevo, en ISO 8601 |

Es la vía fina: la misma señal viaja dentro del `result` de `job.succeeded`, así que si ya lee ese resultado
no necesita suscribirse a este evento para enterarse.

---

## 6 · Reproducción manual

Una entrega ya terminada se puede volver a enviar desde la aplicación de Asixto. Es para el caso real
de toda integración: su receptor respondió `2xx` y su código la procesó mal, o usted arregló el bug
seis horas después de que se agotaran los reintentos. La alternativa —repetir la operación— **se
cobra otra vez**; reproducir no.

Lo que llega es una entrega **nueva**:

| Campo | Qué trae en una reproducción |
|---|---|
| `id` | **Nuevo**, derivado del original (`{id original}:r{n}`). Es lo que su receptor deduplica. |
| `replay_of` | El `id` del evento **original**. Es la referencia con la que correlaciona. |
| `data` | El mismo cuerpo que se envió entonces, sin recomponer. |

**Deduplique por `id`; correlacione por `replay_of`.** Esa separación es todo el mecanismo: si la
reproducción repitiera el `id` original, un receptor bien implementado la descartaría por duplicado
—que es exactamente lo que le pedimos en la sección 4— y el botón no serviría de nada.

No parsee el `id` para extraer el original: use `replay_of`, que es el campo que existe para eso.

Reproducir **no vuelve a ejecutar la operación** y no toca su facturación: reenvía el cuerpo
guardado, tal cual. Y no altera el historial de la entrega original, que se conserva con sus intentos
y su último error.

---

## 7 · Acuse y reintentos

**El único acuse es un 2xx.** Respóndalo en cuanto tenga la entrega a salvo —en su propia cola, en su
base— y procese después. Su tiempo de proceso no debería depender de nada que pueda tardar.

| Su respuesta | Qué hace el Gateway |
|---|---|
| **2xx** | Entregada. Fin. |
| **410 Gone** | **Fallo permanente**: no se reintenta. Es el único código con el que usted declara que ese destino ya no existe. |
| Cualquier otro código, incluidos `400`, `401`, `403` y `404` | **Se reintenta.** Es deliberado: en la práctica un `4xx` de un receptor sano viene de un despliegue a medias, de un WAF o de un balanceador que aún no conoce la ruta, y todos se arreglan solos en minutos. |
| **3xx** | Se trata como fallo y se reintenta. **Las redirecciones no se siguen**: cada salto sería un destino que nadie validó. Registre la URL final, no una que redirija. |
| Sin respuesta en **10 s** | Fallo del intento. Se reintenta. |

La escalera es de **siete intentos** repartidos en unas diez horas:

| Intento | Cuándo |
|---|---|
| 1 | Inmediato |
| 2 | ~1 min después |
| 3 | ~5 min |
| 4 | ~15 min |
| 5 | ~1 h |
| 6 | ~3 h |
| 7 | ~6 h |

Los retardos son aproximados a propósito: cada uno se sortea entre la mitad y el total del valor
nominal. Sin ese sorteo, una caída nuestra sincronizaría a todos los receptores y volverían todos en
el mismo segundo contra sistemas que acaban de levantarse.

Agotada la escalera, la entrega queda **agotada**: no se vuelve a intentar por sí sola, y a partir de
ahí el único camino es la reproducción manual (sección 6). Una entrega agotada cuenta para la
desactivación de la sección 8.

El cuerpo de una entrega no pasa de **256 KB**. No es un límite contra el que deba dimensionar: es un
techo para que un aviso no se convierta en una transferencia.

---

## 8 · Desactivación por fallos sostenidos

Un endpoint que deja de recibir de verdad se **desactiva**, y hacen falta las dos cosas a la vez:

| Condición | Valor |
|---|---|
| Entregas agotadas desde su último éxito | **5** (son 35 intentos fallidos contra ese destino: ya no es una ráfaga) |
| Tiempo sin ninguna entrega exitosa | **24 h** |

La conjunción es la decisión, no un detalle. Solo con la racha, un endpoint muy usado se apagaría por
encajar una ráfaga de minutos durante un despliegue suyo. Solo con la antigüedad, se apagaría un
endpoint que nadie usa: un tenant que no cerró ningún trabajo en un día no tiene éxito reciente
porque no le mandamos nada, no porque esté roto.

Cuando ocurre, sus **otros endpoints activos** reciben `webhook_endpoint.disabled`. Es la razón
práctica para registrar más de uno: un endpoint aparte —el de su equipo de guardia— es lo único que
puede enterarse de que el principal dejó de recibir.

> **La reactivación no es automática.** A diferencia de sus herramientas MCP, que vuelven a ofrecerse
> solas cuando su servidor responde de nuevo ([página 10](./10-operation.md)), un endpoint de webhook
> desactivado **no se reactiva por recibir tráfico**: no le llega ninguno. Arregle su receptor y
> pídalo por el canal de la página 10.

---

## 9 · El endpoint

| Dato | Exigencia |
|---|---|
| URL de destino | **DEBE** ser un destino público alcanzable desde internet. Se valida al darla de alta **y otra vez en cada entrega**: un nombre que resuelva a una dirección interna, a `localhost` o al servicio de metadatos de una nube se rechaza. **DEBERÍA** ser HTTPS: el sobre lleva datos de su operación. |
| Entorno | **DEBE** declararse: `live` o `test`. Un endpoint solo recibe los eventos de su entorno, así que su entorno de pruebas puede tener su propio receptor y su propio secreto. |
| Eventos | **DEBE** ser una lista del catálogo de la sección 5. |
| Cuántos | **PUEDE** registrar varios. Cada uno con su secreto propio: revocar uno no le obliga a rotar los demás. |

El secreto de firma se muestra **una sola vez**, al dar de alta el endpoint y al rotar. Si lo pierde,
el camino es rotar: se emite otro sin dejar de firmar con el anterior (sección 3).

---

## Lista de comprobación de su receptor

| # | Comprobación |
|---|---|
| 1 | Verifica la firma sobre el **cuerpo crudo**, antes de parsear el JSON |
| 2 | Recorre **todas** las firmas `v1=` y admite **dos** secretos a la vez |
| 3 | Compara los HMAC en **tiempo constante** |
| 4 | Rechaza el instante fuera de ±300 s, con diferencia **absoluta** |
| 5 | Rechaza la cabecera ausente, repetida o malformada con `400` |
| 6 | Deduplica por `id` con un TTL de al menos **7 días** |
| 7 | Correlaciona una reproducción por `replay_of`, sin parsear el `id` |
| 8 | Responde **2xx** antes de procesar, y procesa en su propia cola |
| 9 | No depende del orden de llegada: usa `created` |
| 10 | Registra el `id` y el `type` de cada entrega, aceptada o rechazada, para poder cerrar un incidente citándolos |

---

<!-- fuente: https://gateway.asixto.com/docs/12-pricing.md -->

# 12 · Precios

Lo que consume cada operación de la API, en créditos. El turno del agente se factura con **un solo código y un
solo precio**; el resto, cada uno por su propia unidad. Aquí están los dos.

## Qué es un turno

Un mensaje que usted envía a la API, atendido por el agente, con su respuesta. Una conversación son
tantos turnos como mensajes atienda; lo que sigue es el precio de **uno**.

## El precio del turno

| Código de facturación | Créditos | Unidad |
|---|---|---|
| `agent_turn` | 11 | petición |

**11 créditos por turno, siempre.** No es un techo ni un rango: no hay ningún cobro condicional, así
que puede calcular su consumo multiplicando por el número de turnos que espera atender. El precio
incluye la generación de la respuesta y la clasificación de la intención del mensaje, que ocurren
juntas y no se facturan por separado.

Un turno que **no produce respuesta** no cobra nada. Si la generación falla —nuestro servicio, no su
servidor— el crédito reservado se le devuelve.

**Se cobra una vez por turno.** El cobro lleva el identificador del trabajo (`job_id`), así que un
reintento suyo con la misma clave de idempotencia, o un reintento interno nuestro, no cobra dos veces
([página 05](./05-guarantees.md)).

> **Es el precio de un turno de TEXTO.** Si el mensaje trae un adjunto —audio, imagen, documento o
> vídeo—, además se cobra el análisis de ese adjunto, y **no por turno sino por su propia unidad**
> (segundos de audio, la imagen, el documento). Por eso no entra en la cifra de arriba: un precio que
> incluyera un audio no tendría cota. El consumo de cada adjunto aparece en su propia línea del
> desglose del periodo, con su código y su cantidad.

## Por qué el turno de la API tiene su propio código

Porque es su tráfico y solo el suyo. Si compartiera código con el agente que atiende los canales de
la plataforma, una empresa que use las dos cosas vería **una sola línea** en su desglose mezclando lo
que consumió por WhatsApp con lo que consumió por la API, sin forma de separarlas. Con un código
propio, `agent_turn` es exactamente lo que le costó integrar.

**Todas las cifras de esta página son las del sistema, no estimaciones de la documentación.** Salen del
catálogo de precios que aplica el cobro, que es su única fuente. Y el importe que se le aplicó en cada
petición viaja en su respuesta (`usage`), así que no hace falta creerle a esta tabla: se concilia contra el
dato de la propia llamada.

## Las demás operaciones, cada una por su unidad

Estas no se cobran por petición sino por lo que consumen, y el importe aplicado viaja en el `usage` de cada
respuesta con su código y su cantidad, así que se puede conciliar sin esperar la factura.

| Código de facturación | Créditos | Unidad | Mínimo facturado |
|---|---|---|---|
| `image_analysis` | 55 | imagen | 1 imagen |
| `document_analysis` | 40 | documento | 1 documento |
| `video_analysis` | 270 | video | 1 video |
| `audio_transcription` | 1 | segundo de audio | **10 segundos** |
| `tts_synthesis` | 1 | carácter normalizado | 1 carácter |
| `sentiment_analysis` | 1 | petición | 1 petición |
| `voice_call_minute` | 620 | minuto de llamada con IA | 1 minuto |
| `voice_transfer_minute` | 230 | minuto de **una** pata de telefonía puenteada | 1 minuto |

`video_analysis` es la única de tarifa **plana**: se cobra por video, no por su duración, así que uno de diez
segundos y uno de cinco minutos cuestan los mismos 270 créditos. Es también por lo que su entrada tiene techo
de tamaño y de duración, y por lo que un video que los pasa se rechaza en vez de analizarse a medias
([página 15](./15-api-reference.md)).

Los mínimos no son un margen nuestro: la transcripción tiene un **mínimo facturable de 10 segundos** porque es
lo que factura el proveedor, y los minutos redondean hacia arriba porque es lo que factura la telefonía. Una
nota de tres segundos se cobra por diez, y la cantidad que se aplicó siempre está en `usage.quantity`: si suma
las duraciones de sus audios sin ese suelo, su total no cuadrará con el desglose del periodo.

`tts_synthesis` cobra el texto **normalizado**, no el que usted escribió: «Dr.» se dice «Doctor» y «999» se
dice «novecientos noventa y nueve». Las cabeceras `X-Asixto-Chars-*` de la respuesta traen las tres cifras
—enviados, normalizados y cobrados— para que el número cuadre ([página 15](./15-api-reference.md)).

El análisis de sentimiento es una operación aparte y el turno **no** lo hace por su cuenta: si lo quiere, lo
pide y lo paga.

### Una llamada transferida a una persona cobra dos tramos

Cuando el agente puentea la llamada con alguien de su equipo, el tiempo se reparte en dos y **cada tramo se
cobra con su propio código**, cada uno redondeando su minuto:

| Tramo | Código | Lo que se factura |
|---|---|---|
| Con el agente | `voice_call_minute` | 620 créditos por minuto |
| Puenteado con la persona | `voice_transfer_minute` | **dos** patas por minuto, o **460 créditos por minuto** |

El tramo puenteado cuesta menos por pata porque ahí no corre nada de IA —ni voz a texto, ni modelo, ni
síntesis—: es telefonía de operador. Y son **dos** patas porque durante el puente hay dos llamadas vivas, la
original y la que abre hacia la persona, y el proveedor factura las dos.

Un intento de transferencia sobre una llamada que nadie descolgó no cobra nada, y se le informa igualmente de
que el traspaso se pidió.

## El entorno de pruebas cuesta lo mismo

🔴 **`ak_test_` consume créditos a estos mismos precios.** El entorno de pruebas ejecuta contra los mismos
proveedores que producción, así que no hay tarifa reducida ni exención: lo que cambia entre los dos entornos es
la concurrencia, a quién se puede llamar y el aislamiento de los datos, no el precio
([página 14](./14-api-quickstart.md)). Todo consumo se cobra en cualquier entorno y salga la llamada por el
número que salga. Presupueste sus pruebas con esta tabla.

**Verificar un número de prueba no le cuesta créditos.** El código que le enviamos para verificarlo lo paga
Asixto; por eso sus envíos tienen límites ([página 14](./14-api-quickstart.md)). Las llamadas que haga después a
ese número sí se cobran, a los precios de arriba.

## Los créditos de bienvenida

Su cuenta recibe **3.000 créditos de bienvenida, una sola vez**: no es una bolsa mensual y no se repone. Se
acreditan solos cuando la cuenta cumple las dos condiciones:

- tiene el módulo del Gateway, y
- tiene un **medio de pago verificado**: el medio por defecto con el que se le cobran las facturas.

Llegan en cuanto se cumple la última de las dos, normalmente al verificar su tarjeta. Si en ese momento algo
falla, un proceso que repasa las cuentas cada 15 minutos los acredita en su siguiente vuelta. Una cuenta con
el acceso cortado por impago no los recibe hasta que se reactiva.

Son créditos como los que usted compra: se consumen a los mismos precios, en los dos entornos, y no caducan
con el cambio de mes. Darse de baja del módulo y volver no concede una segunda bienvenida.

## Lo que NO entra en el precio del turno

- **Las llamadas a su servidor de herramientas** no se le cobran a usted: el turno cuesta lo mismo si
  el agente consulta su catálogo tres veces o ninguna. Lo que sí está acotado son las rondas por
  turno ([página 07](./07-limits.md)).
- **Leer el stream de una respuesta** no cuesta nada, ni la primera vez ni al reanudarlo: el cobro
  sigue a la generación, no a la entrega. Si su cliente se desconecta a mitad, el turno cuesta lo
  mismo.
- **El precio del crédito en dinero** y las bolsas de cada plan: eso está en la página pública de precios, no
  aquí. Esta página cuenta cuántos créditos consume cada operación, no cuánto vale un crédito.

## Cómo verificarlo en su cuenta

Cada respuesta de la API declara el consumo de la operación que la atendió (`usage`), y el desglose
por código de cada periodo está en su consumo de la cuenta. Si su suma no cuadra con esta tabla, es un
fallo nuestro: repórtelo con el identificador de la petición ([página 10](./10-operation.md)).

---

<!-- fuente: https://gateway.asixto.com/docs/13-data.md -->

# 13 · Datos, retención y subencargados

Qué guarda Asixto de su tráfico, cuánto tiempo, quién más lo ve y qué no se hace con él. Todo con
cifra: esta página existe porque «la ventana acordada para su cuenta» no es un dato que nadie pueda
verificar.

**Alcance.** Cubre lo que entra por la API del Gateway (`/v1`) y por el contrato de su servidor de
herramientas. El contenido que llegue a Asixto por los canales de la plataforma —WhatsApp, correo,
web— se rige por los términos de la plataforma y no por esta página.

## Lo que se guarda, y cuánto vive

| Qué | Cuánto vive | Cómo desaparece |
|---|---|---|
| La **entrada** de una petición (su texto, su audio, su documento) | No se persiste | Vive en memoria durante la petición. Lo vigila un invariante del propio código |
| El **resultado** de una operación, que es lo que devuelve repetir su clave de idempotencia | **24 h** | Un barrido cada hora lo purga |
| La **ventana de conversación** de una sesión del agente | **24 h de inactividad** | El barrido borra la fila entera, no la vacía |
| El **registro de uso**: operación, código de respuesta, latencia, créditos, prefijo de la clave e identificador de la petición. **Sin su contenido, sin su clave completa y sin su IP** | **400 días** | Un barrido diario borra la fila pasada la ventana |
| El **asiento contable** de cada cobro: operación, cantidad y créditos | El plazo legal | No se borra: es la evidencia de una factura ya emitida |
| El **registro de entrega** de un webhook: evento, intentos y último error, **sin el cuerpo** | Se conserva | No se borra: es lo que sostiene la deduplicación ([página 11](./11-webhooks.md)) |
| Lo que su servidor de herramientas devuelve | Entra en el historial de esa conversación | Ni los argumentos que le enviamos ni su respuesta se copian a otro almacén |

Dos precisiones que cambian el cálculo:

- **La ventana de la sesión cuenta desde la última actividad, no desde que se abrió.** Una
  conversación que sigue viva no se corta a mitad; una que se abandona desaparece 24 h después del
  último turno.
- **La ventana de retención de 24 h es la de todas las cuentas.** No hay retención configurable por
  cliente, y por eso aquí va el número y no una remisión a su contrato. Si necesita otra ventana, es
  una conversación previa a la firma, no un ajuste que se pueda activar después.
- **Los 400 días del registro de uso son fijos y tampoco se negocian.** Dentro de la ventana usted
  consulta el detalle petición a petición y el resumen por operación; Asixto se reserva servir solo
  el **resumen** para los tramos más antiguos, porque es lo que sostiene la conciliación de una
  factura y es lo que no crece sin límite. La ventana cubre un año cerrado más el margen para
  reclamar el último periodo, y si necesita historia más larga, expórtela usted: el resumen de uso
  y los webhooks son continuos, y su almacén no tiene por qué ser el nuestro.

## Lo que NO se hace con su contenido

- **No se usa para entrenar ningún modelo.** No existe una sola llamada a un servicio de ajuste de
  modelos ni de subida de conjuntos de datos en ningún componente de Asixto, y lo sostiene un
  verificador que se pone rojo el día que aparezca.
- **Ningún índice mezcla el contenido de dos empresas.** Toda escritura y toda consulta de la
  memoria semántica llevan el identificador de la empresa, y su credencial es el techo: no existe
  una consulta sin empresa que devuelva contenido.
- **Del programa de mejora de modelos del proveedor de transcripción en tiempo real se sale
  explícitamente**, en cada conexión y sin excepción por camino. Ese proveedor participa por
  omisión, así que no salir habría significado que el audio de sus clientes finales alimentara los
  modelos de un tercero sin que nadie se lo hubiera preguntado.
- **Su catálogo no se replica.** El agente lo consulta contra su servidor cuando lo necesita; no se
  construye una copia paralela ([página 10](./10-operation.md)).

## Quién más ve su contenido

Asixto ejecuta cada operación con proveedores especializados. Estos son los que reciben contenido
suyo, por operación:

| Operación | Tipo de proveedor | Qué le llega |
|---|---|---|
| `/v1/vision/analysis` | Modelo de lenguaje | La imagen y la instrucción de análisis |
| `/v1/documents/analysis` | Modelo de lenguaje | El texto extraído del documento |
| `/v1/video/analysis` | Modelo de lenguaje con visión | Los fotogramas que Asixto extrae del video, no el archivo |
| `/v1/audio/transcriptions` | Transcripción de audio | El audio a transcribir |
| `/v1/text/sentiment` | Modelo de lenguaje | El texto a analizar |
| `/v1/audio/speech` | Síntesis de voz | El texto a sintetizar |
| `/v1/agent/turns` | Modelo de lenguaje | El mensaje, el historial que usted envía y la ventana de la sesión |
| `/v1/calls` | Telefonía · transcripción de audio · síntesis de voz · modelo de lenguaje | La señal telefónica, el audio del cliente final, el texto del agente y la conversación |
| Cualquiera con herramientas | **Su propio servidor** | Los argumentos que el agente decide, con el sobre `com.asixto/` ([página 02](./02-tool-contract.md)) |

Dos cosas que conviene saber de esa lista:

- **El proveedor de modelo no retiene las peticiones de inferencia ni entrena con ellas**, según su
  propia política de datos. Es la razón por la que es el proveedor del camino de texto.
- **El proveedor de síntesis de voz conserva un registro de cada petición en su plataforma.** Su
  modo de retención cero está reservado a contratos de empresa, así que hoy no se puede prometer lo
  contrario y se declara.

**La identidad de cada proveedor no se publica aquí**, y no es opacidad: la lista nominal se entrega
bajo acuerdo de confidencialidad al contacto técnico que la pida ([página 10](./10-operation.md)),
que es donde puede figurar en un anexo con valor contractual. Lo que sí es público es lo de arriba —
qué contenido sale y hacia qué tipo de proveedor—, porque es lo que usted necesita para decidir qué
nos manda.

Un cambio de proveedor, o uno nuevo que reciba contenido de clientes, se anuncia por el canal de
cambios ([página 09](./09-changelog.md)) antes de entrar en producción.

## Borrado a petición

| Qué se puede borrar | Cómo |
|---|---|
| El contenido de una conversación concreta, incluida la de su cliente final | Por el contacto técnico ([página 10](./10-operation.md)), citando el identificador de la conversación |
| Todo el contenido de su cuenta | Con la baja de la cuenta, que además libera los recursos que Asixto tenga alquilados en proveedores por usted |

No se borra el asiento contable: es la evidencia de una factura ya emitida, y sin él ninguna de las
dos partes podría reclamar nada. Para el contenido de una conversación concreta no hay endpoint de
autoservicio — es una petición al contacto técnico, y así se publica en vez de prometer un botón que
no existe.

**La baja de la cuenta sí es autoservicio, y tiene cifras.** La pide **el Propietario** de la
empresa desde la plataforma, y a partir de ahí:

- Se abre una **ventana de gracia de 30 días** antes de que se ejecute nada. Durante ese plazo la
  baja se **cancela** desde la misma pantalla, y volver a pedirla reactiva la misma solicitud en vez
  de abrir otra.
- La solicitud emite un **código de confirmación** con el que se consulta su estado.
- Al ejecutarse se borra el contenido de su cuenta en TODAS las bases, incluido todo lo del Gateway:
  claves de API, servidores MCP registrados, trabajos, registro de uso y conversaciones. No hay una
  lista de tablas que alguien tenga que recordar actualizar — el borrado recorre el modelo de datos
  entero por su empresa.
- **Lo que sobrevive, y por qué**: los registros de facturación, anonimizados, porque la ley obliga
  a conservarlos; y los recursos alquilados en proveedores por usted —como el número de teléfono—
  que se liberan en el proveedor y dejan constancia de haberlo hecho.

**La grabación de la llamada no entra en ninguna de las dos cosas, porque no existe.** Asixto no
almacena el audio de una llamada de `/v1/calls`: no hay dónde guardarlo. Lo que queda de una llamada
es su trabajo, su duración facturada y su transcripción cuando la conversación la produjo. Si algún
día se ofreciera grabación, sería una función nueva y anunciada, nunca un cambio silencioso.

## Lo que no se le cobra

- **Si el proveedor cae, no se cobra.** La operación responde con el error de proveedor no
  disponible y su `Retry-After`, y las unidades autorizadas se devuelven.
- **Si el proveedor responde pero no entrega lo que se pidió, tampoco se cobra**, y es el mismo
  error: un análisis que se degrada, una transcripción que se descarta por incoherente o un turno
  que solo alcanza a disculparse cuentan como no entregados. Ese caso se cobraba antes y era un
  defecto, no una política.
- **Cuando el resultado no se entrega, la clave de idempotencia queda gastada**: la repetición
  devuelve el mismo error, así que para volver a intentarlo use una clave nueva
  ([página 05](./05-guarantees.md)).
- **Si Asixto no puede medir la cantidad consumida** —duración de un audio, minutos de una
  llamada— se cobra el mínimo facturable de esa operación, nunca el máximo autorizado. El techo
  autoriza; no factura ([página 12](./12-pricing.md)).

---

<!-- fuente: https://gateway.asixto.com/docs/14-api-quickstart.md -->

# 14 · La API del Gateway: primeros pasos

Esta es la **etapa 2**: sus sistemas llaman a Asixto. La etapa 1 —su servidor de herramientas— es lo que
cubren las páginas 01 a 10, y las dos son independientes: puede llamar a esta API sin haber publicado
ningún servidor.

**Solo HTTP.** No hay SDK que instalar ni versionar: la superficie es JSON sobre HTTPS y cualquier cliente
HTTP sirve. Cada muestra de estas páginas viene en **cURL, Node.js, Python, PHP y Go** —las cinco generadas
del mismo descriptor de la operación, así que describen exactamente la misma llamada— y ninguna imprime su
clave: va por variable de entorno.

## Lo que necesita antes de la primera llamada

| | |
|---|---|
| Una **clave de API** | Se la entregamos en la incorporación. Empieza por `ak_test_` o por `ak_live_` |
| La **URL base** | Se la damos con la clave. Todas las rutas de esta página van bajo `/v1` |
| Nada más | Sin registro de aplicación, sin OAuth, sin lista de direcciones IP obligatoria |

## Los dos entornos, y en qué se diferencian

El entorno va **dentro de la clave**, así que quien la ve en un registro o en un archivo de configuración
sabe contra qué está operando.

| | `ak_test_` | `ak_live_` |
|---|---|---|
| ¿Se ejecuta de verdad? | **Sí**, con los mismos proveedores | Sí |
| ¿Consume créditos? | **Sí, al mismo precio** | Sí |
| ¿Puede recibir un `402`? | **Sí**, si se queda sin saldo o llega a su tope | Sí |
| Concurrencia | Propia de este entorno | Propia de este entorno |
| Trabajos que puede leer | Solo los de pruebas | Solo los de producción |
| Número desde el que sale una llamada | El **nuestro**, compartido | El **suyo**, obligatorio |
| A quién puede llamar | Solo a sus **números de prueba verificados** | A quien usted decida |

🔴 **El entorno de pruebas no es gratis, y es lo primero que hay que dimensionar.** Ejecuta contra los mismos
proveedores que producción, así que cada prueba nos cuesta lo que cuesta la operación y se le cobra a la misma
tarifa ([página 12](./12-pricing.md)). La alternativa —devolver una respuesta simulada— haría que una
integración probada no significara nada: estaría certificando contra un resultado que su cliente final nunca
va a recibir.

**Lo que acota el gasto de sus pruebas es su tope de gasto, no el entorno.** Pruebas y producción gastan del
mismo saldo y ninguno tiene techo de créditos propio; lo que sí es propio de cada entorno es la concurrencia
([página 18](./18-api-limits.md)).

La otra diferencia real está en la telefonía: una llamada de `ak_test_` sale con **nuestro** número
compartido, y una de `ak_live_` con el de su cuenta. Comprar y mantener un número por cuenta solo para probar
es una renta que nadie paga; sacar tráfico de producción por un número ajeno le mostraría al cliente final un
número que no es el suyo.

Un trabajo no cruza de entorno. Cuando promocione de pruebas a producción, cambia la clave, y su cuenta
necesita su número propio listo (abajo).

## Llamadas: qué poner en `to` y a quién puede llamar

`to` va en **E.164**: el `+`, el indicativo de país y el número, sin espacios ni guiones (`+573001112233`). No
adivinamos el país de un número local: un valor sin esa forma recibe `400 validation_error`.

### Con `ak_test_`: solo a sus números de prueba verificados

Como la llamada de pruebas sale con nuestro número, solo puede ir a un número que usted haya demostrado que es
suyo. Se dan de alta en la consola, en **Números de prueba**:

1. Registre el número. Le enviamos un código por SMS.
2. Escriba el código en la consola. El número queda **verificado** y ya puede llamarlo.

- **Hasta 5 números por cuenta**, contando los pendientes de verificar y los verificados. Uno pendiente que
  no verifica en 30 minutos caduca y libera su hueco; quitar uno también lo libera.
- **El envío del código lo paga Asixto**, no sus créditos. Por eso tiene límites en una ventana de 24 horas:
  10 envíos por cuenta, 5 al mismo número sumando todas las cuentas que lo registren, y un presupuesto global
  de la plataforma. Cada intento cuenta contra el límite de su cuenta aunque luego lo frene el del número o el global.
- Llamar con `ak_test_` a un número que no es uno de sus verificados responde
  `409 test_destination_not_verified`, no se cobra y no consume la `Idempotency-Key`: verifíquelo y reintente
  con la misma clave.

**La transferencia a una persona también va solo a un número verificado.** Al guardar la conducta de llamada de
pruebas, un número de transferencia que no lo esté se rechaza. Si deja de estarlo después —porque lo
quitó: un número verificado no caduca—, la llamada **sale igual, sin transferencia**, y el acuse lo declara en
`warnings.transfer_disabled` ([página 15](./15-api-reference.md)).

### Con `ak_live_`: su número propio, obligatorio

En producción la llamada sale **siempre** con el número de su cuenta; ya no hay número de Asixto de reserva. Si
el suyo no está listo, la llamada no sale, no se cobra y no consume la `Idempotency-Key`:

| Lo que recibe | Cuándo | Qué hacer |
|---|---|---|
| `409 phone_number_required` | No tiene número, o su expediente de reputación falta, está incompleto o fue rechazado | Compre su número, o cree, complete o corrija el expediente en la consola |
| `409 phone_number_in_review` | Su número está en provisión, o su expediente en revisión del operador | Esperar: cuando la consola lo muestre listo, la misma petición funciona |
| `402 phone_number_suspended` | Su número está suspendido porque venció la gracia del pago de su cuota | Ponerse al día con el pago; vuelve a servicio sin cambiar de clave |

Si el fallo es nuestro —la provisión falló en el proveedor o un dato de su número es inconsistente—, recibe un
`5xx` y lo atendemos nosotros: no hay nada que cambiar en su lado ([página 16](./16-api-errors.md)).

## Su primera llamada

**cURL**

```bash
curl -sS -X POST "$ASIXTO_BASE_URL/v1/text/sentiment" \
  -H "Authorization: Bearer $ASIXTO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: sent-4471" \
  -d '{"text":"Llevo tres días esperando y nadie me responde.","customer_id":"cliente-12345"}'
```

**Node.js**

```js
const respuesta = await fetch(`${process.env.ASIXTO_BASE_URL}/v1/text/sentiment`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.ASIXTO_API_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': 'sent-4471',
  },
  body: JSON.stringify({
    "text": "Llevo tres días esperando y nadie me responde.",
    "customer_id": "cliente-12345"
  }),
});

const sobre = await respuesta.json();
console.log(sobre.job_id, sobre.usage.credits, sobre.result);
```

**Python**

```python
import os
import requests

url = f"{os.environ['ASIXTO_BASE_URL']}/v1/text/sentiment"
cabeceras = {
    "Authorization": f"Bearer {os.environ['ASIXTO_API_KEY']}",
    "Idempotency-Key": "sent-4471",
}

cuerpo = {
  "text": "Llevo tres días esperando y nadie me responde.",
  "customer_id": "cliente-12345"
}

respuesta = requests.post(url, headers=cabeceras, json=cuerpo, timeout=120)
respuesta.raise_for_status()

sobre = respuesta.json()
print(sobre["job_id"], sobre["usage"]["credits"], sobre["result"])
```

**PHP**

```php
<?php

$url = getenv('ASIXTO_BASE_URL') . '/v1/text/sentiment';
$cabeceras = [
    'Authorization: Bearer ' . getenv('ASIXTO_API_KEY'),
    'Content-Type: application/json',
    'Idempotency-Key: sent-4471',
];

$cuerpo = {
  'text': 'Llevo tres días esperando y nadie me responde.',
  'customer_id': 'cliente-12345'
};

$ch = curl_init($url);
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => $cabeceras,
    CURLOPT_POSTFIELDS => json_encode($cuerpo),
]);

$respuesta = curl_exec($ch);
curl_close($ch);

$sobre = json_decode($respuesta, true);
echo $sobre['job_id'], ' ', $sobre['usage']['credits'], PHP_EOL;
```

**Go**

```go
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"strings"
)

func main() {
	cuerpo := strings.NewReader(`{"text":"Llevo tres días esperando y nadie me responde.","customer_id":"cliente-12345"}`)

	peticion, _ := http.NewRequest("POST", os.Getenv("ASIXTO_BASE_URL")+"/v1/text/sentiment", cuerpo)
	peticion.Header.Set("Authorization", "Bearer "+os.Getenv("ASIXTO_API_KEY"))
	peticion.Header.Set("Content-Type", "application/json")
	peticion.Header.Set("Idempotency-Key", "sent-4471")

	respuesta, err := http.DefaultClient.Do(peticion)
	if err != nil {
		panic(err)
	}
	defer respuesta.Body.Close()

	var sobre map[string]any
	json.NewDecoder(respuesta.Body).Decode(&sobre)
	fmt.Println(sobre["job_id"], sobre["usage"], sobre["result"])
}
```

```json
{
  "request_id": "req_ec981f75a0380ea072e42485",
  "job_id": "19f77b69-4a92-4821-ab3e-7fce38b38953",
  "usage": { "operation": "sentiment_analysis", "unit": "request", "quantity": 1, "credits": 1 },
  "result": { "sentiment": "positive", "score": 0.9, "source": "nlp" }
}
```

## El sobre: cuatro campos, siempre los mismos

Toda respuesta correcta tiene esta forma, para las ocho operaciones. Escriba su capa de transporte una vez
y reutilícela.

| Campo | Qué es |
|---|---|
| `request_id` | Correlación de **esta** petición. Es lo que citamos en un incidente |
| `job_id` | El trabajo que la atendió. Con él consulta su estado más tarde ([página 17](./17-jobs.md)) |
| `usage` | Lo que se le cobró: `operation`, `unit`, `quantity` y `credits` |
| `result` | El resultado de la operación |

**El consumo viaja en la respuesta, no solo en la factura.** Es lo que le permite repercutir el coste a su
propio cliente en el momento en vez de enterarse a fin de mes, y es el mismo dato que verá en su desglose,
así que las dos cifras se concilian.

`usage.quantity` son las unidades **cobradas**, no las autorizadas.

La única excepción a la forma es `POST /v1/audio/speech`, que devuelve el audio como cuerpo binario: ahí el
sobre viaja en cabeceras `X-Asixto-*` con el mismo significado ([página 15](./15-api-reference.md)).

## `Idempotency-Key`: la mande siempre

Es una cabecera suya, un texto que usted elige, y es lo que convierte un reintento en algo seguro.

- **Misma clave, operación terminada** → se le devuelve el mismo `job_id` y el mismo `usage`, **sin volver
  a ejecutar y sin volver a cobrar**.
- **Misma clave, operación todavía en curso** → `409 request_in_flight`. No es un error suyo: es la verdad.
- **Misma clave para otra operación, o con otras `variables`** → `409 idempotency_key_reused`.

Sin ella, un tiempo de espera agotado en su lado le deja sin saber si la operación ocurrió, y el único
camino es preguntar o pagar dos veces. Los cinco casos de una repetición están en la
[página 17](./17-jobs.md).

## Autenticación

```
Authorization: Bearer ak_live_…
```

Si falta o no tiene esa forma: `401 authentication_required`. Si la clave no existe, está revocada o
caducó: `401 invalid_api_key` — **no distinguimos entre los tres a propósito**, porque decir cuál es
ayudaría a quien está probando claves.

Cada clave lleva sus **permisos** —ocho en total, uno por capacidad más el de lectura— y puede llevar una
lista de direcciones IP autorizadas. Si su clave no tiene el permiso de la operación:
`403 insufficient_scope`, y la respuesta declara en `WWW-Authenticate` cuál hacía falta. Qué autoriza cada
uno, y cuál conviene conceder siempre, está en la [página 15](./15-api-reference.md).

Los permisos se fijan **al emitir** y no se editan después: si le falta uno, emita otra clave. Cada entorno
admite dos claves activas a la vez, así que puede desplegar la nueva y revocar la anterior sin ventana de
caída.

## Errores: programe contra el `code`

```json
{
  "error": {
    "code": "validation_error",
    "message": "agent_id debe ser el identificador de un agente de su cuenta.",
    "request_id": "req_22398948bdff3f425bdeb7da",
    "doc_url": "https://asixto.com/docs/gateway/errors#validation_error"
  }
}
```

El **`code` es lo único estable**: el estado HTTP significa cosas distintas según el cliente, el texto se
reescribe y el idioma se traduce. El catálogo completo, con qué hacer en cada caso, está en la
[página 16](./16-api-errors.md).

Dos reglas que puede dar por buenas: **un fallo de negocio nunca llega como `5xx`**, y los errores
temporales traen `Retry-After` en segundos.

## Qué hacer a continuación

| Para | Vaya a |
|---|---|
| La referencia de las ocho operaciones | [página 15](./15-api-reference.md) |
| Qué significa cada error y si conviene reintentar | [página 16](./16-api-errors.md) |
| Trabajos asíncronos, consulta de estado y reintentos | [página 17](./17-jobs.md) |
| Recibir eventos en su sistema | [página 11](./11-webhooks.md) |
| Qué guardamos de su tráfico y quién más lo ve | [página 13](./13-data.md) |
| Cuánto cuesta un turno del agente | [página 12](./12-pricing.md) |

---

<!-- fuente: https://gateway.asixto.com/docs/15-api-reference.md -->

# 15 · Referencia de las operaciones

Las ocho operaciones de `/v1`, con su permiso, su unidad de cobro y su contrato de entrada. La forma del
sobre y de los errores está en las páginas [14](./14-api-quickstart.md) y [16](./16-api-errors.md).

Todos los nombres de campo van en `snake_case`, igual que el sobre de uso y el cuerpo de error.

## Resumen

| Operación | Permiso | Se factura como | Unidad | Respuesta |
|---|---|---|---|---|
| `POST /v1/vision/analysis` | `vision:analyze` | `image_analysis` | imagen | `200` con sobre |
| `POST /v1/documents/analysis` | `documents:analyze` | `document_analysis` | documento | `200` con sobre |
| `POST /v1/video/analysis` | `video:analyze` | `video_analysis` | video | `200` con sobre |
| `POST /v1/audio/transcriptions` | `audio:transcribe` | `audio_transcription` | segundo | `200` con sobre |
| `POST /v1/audio/speech` | `speech:synthesize` | `tts_synthesis` | carácter | `200` **binario** |
| `POST /v1/text/sentiment` | `sentiment:analyze` | `sentiment_analysis` | petición | `200` con sobre |
| `POST /v1/calls` | `calls:initiate` | `voice_call_minute` | minuto | `202` con acuse |
| `POST /v1/agent/turns` | `agent:converse` | `agent_turn` | petición | `202` con acuse |
| `GET /v1/jobs/{job_id}` | `jobs:read` | no se cobra | — | `200` ([página 17](./17-jobs.md)) |
| `GET /v1/agents` | `agents:read` | no se cobra | — | `200` con la lista de sus agentes activos |

Las seis primeras responden con el resultado en la misma llamada. Las dos asíncronas —la llamada y el turno—
responden un acuse y el resultado llega después. Las dos últimas son LECTURAS y no cobran nada.

Hay una entrada más que no aparece arriba porque no es una operación que se pida, sino la lectura de una que
ya pidió: el stream del turno, en `GET /v1/agent/streams/{job_id}`, cuya URL exacta le llega en el acuse. Lo
autoriza `jobs:read`, y su contrato está en la [página 19](./19-api-stream.md).

### De dónde sale el `agent_id`

`POST /v1/agent/turns` y `POST /v1/calls` exigen un `agent_id`, y `GET /v1/agents` es el que se lo da: devuelve
sus agentes **activos** con `id`, `name` y `active`. Nada más — la configuración del agente (capacidades,
reglas, canales, voz) se gobierna en la consola y no viaja por esta API.

**cURL**

```bash
curl -sS -X GET "$ASIXTO_BASE_URL/v1/agents" \
  -H "Authorization: Bearer $ASIXTO_API_KEY"
```

**Node.js**

```js
const respuesta = await fetch(`${process.env.ASIXTO_BASE_URL}/v1/agents`, {
  method: 'GET',
  headers: {
    Authorization: `Bearer ${process.env.ASIXTO_API_KEY}`,
  },
});

const sobre = await respuesta.json();
console.log(sobre.job_id, sobre.usage.credits, sobre.result);
```

**Python**

```python
import os
import requests

url = f"{os.environ['ASIXTO_BASE_URL']}/v1/agents"
cabeceras = {
    "Authorization": f"Bearer {os.environ['ASIXTO_API_KEY']}",
}

respuesta = requests.get(url, headers=cabeceras, timeout=120)
respuesta.raise_for_status()

sobre = respuesta.json()
print(sobre["job_id"], sobre["usage"]["credits"], sobre["result"])
```

**PHP**

```php
<?php

$url = getenv('ASIXTO_BASE_URL') . '/v1/agents';
$cabeceras = [
    'Authorization: Bearer ' . getenv('ASIXTO_API_KEY'),
];

$ch = curl_init($url);
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => $cabeceras,
]);

$respuesta = curl_exec($ch);
curl_close($ch);

$sobre = json_decode($respuesta, true);
echo $sobre['job_id'], ' ', $sobre['usage']['credits'], PHP_EOL;
```

**Go**

```go
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"os"
)

func main() {
	peticion, _ := http.NewRequest("GET", os.Getenv("ASIXTO_BASE_URL")+"/v1/agents", nil)
	peticion.Header.Set("Authorization", "Bearer "+os.Getenv("ASIXTO_API_KEY"))

	respuesta, err := http.DefaultClient.Do(peticion)
	if err != nil {
		panic(err)
	}
	defer respuesta.Body.Close()

	var sobre map[string]any
	json.NewDecoder(respuesta.Body).Decode(&sobre)
	fmt.Println(sobre["job_id"], sobre["usage"], sobre["result"])
}
```

```json
{
  "data": [{ "id": "a4f1c2e0-6d3b-4e7a-9c21-8f0e5b7d4a13", "name": "Cobranza", "active": true }],
  "total": 1
}
```

Solo lista agentes que pueden atender un turno ahora mismo: uno desactivado no aparece, porque ofrecerle un
`agent_id` que la operación siguiente rechaza con `400` sería peor que no ofrecerlo. **Crear o editar agentes
no está en `/v1`**: eso vive en la consola, donde está su único escritor.

**Devuelve hasta 100 agentes.** `total` es el número real de activos, así que si su cuenta tuviera más de cien
lo vería ahí, y la respuesta traería además `omitted` con cuántos no cupieron. No hay cursor ni páginas: los
agentes se crean a mano en la consola y cien es holgado para cualquier cuenta. Si llega a ese punto, escríbanos
([página 10](./10-operation.md)) — preferimos hablarlo antes que publicar un formato de paginación que después
no podríamos cambiar. El recorte conserva el orden, así que el `agent_id` que guarde no se le mueve.

Ojo con las claves que ya tenía emitidas: `agents:read` se concede **al emitir**, así que una clave anterior a
este permiso responde `403 insufficient_scope` aquí. Emita una nueva y rótela ([página 9](./09-changelog.md)).

La lista se sirve **con hasta 60 segundos de retraso**: es la misma lectura que usa la consola y va cacheada por
cuenta. Un agente que active ahora puede tardar ese minuto en aparecer aquí, así que no encadene «crear en la
consola» con «listar por API» sin ese margen. Los `agent_id` no cambian, de modo que una vez obtenido puede
guardarlo: esta ruta es para descubrirlo, no para consultarla en cada turno.

## Los diez permisos de una clave

Una clave lleva los permisos que se le concedieron **al emitirla**, y son diez en total: uno por capacidad,
más los dos de lectura. Una clave sin ninguno no autoriza nada y toda petición responde `403 insufficient_scope`.

| Permiso | Qué autoriza | Qué **no** autoriza |
|---|---|---|
| `vision:analyze` | Analizar una imagen | Documentos, audio ni ninguna otra capacidad |
| `documents:analyze` | Analizar un documento que usted aloja por URL | Subir el archivo, ni analizar imágenes |
| `video:analyze` | Analizar un video que usted aloja por URL | Documentos ni imágenes: cada capacidad lleva el suyo |
| `audio:transcribe` | Convertir audio a texto | **Generar** audio: eso es `speech:synthesize` |
| `speech:synthesize` | Generar audio a partir de texto | Leer audio. Leer y generar son permisos distintos a propósito |
| `sentiment:analyze` | Analizar el tono de un texto | Nada del turno del agente: el turno no lo hace por su cuenta |
| `calls:initiate` | **Originar** una llamada de voz con su agente | Leer la llamada: eso es `jobs:read`. Colgarla no existe en la API |
| `jobs:read` | Consultar un trabajo y leer el stream de un turno | Crear nada. Es la única entrada que no gasta créditos ni cupo |
| `agent:converse` | Abrir un turno de conversación con su agente | Leer la respuesta de ese turno: leerla la autoriza `jobs:read` |
| `agents:read` | Listar sus agentes activos para obtener el `agent_id` | Crear, editar ni desactivar un agente: eso es de la consola |

**`calls:initiate` es «originar», no «llamadas».** Un permiso de recurso genérico habría cubierto también
leer o colgar, que son cosas que se pueden querer conceder sin conceder la de marcar: es la única operación
de la superficie que gasta dinero por minuto en un tercero y expone su número al destinatario.

**No hay verbo para colgar una llamada en curso, y no lo damos por supuesto.** La API no publica ninguna ruta
que corte una llamada ya originada: la llamada termina cuando la conversación termina, cuando se agota el plazo
del trabajo o cuando un operador de su empresa pulsa el botón de parada **desde la consola**. Si necesita
detener una llamada, ese es hoy el único camino. Tampoco hay estado `canceled`: los cinco valores de `status`
son los de la [página 17](./17-jobs.md) y ninguno significa «cancelado».

### `jobs:read` es el que le deja leer lo que pagó

Cubre **dos** entradas, y por eso es el que más falta se echa cuando no está: la consulta del trabajo
(`GET /v1/jobs/{job_id}`, [página 17](./17-jobs.md)) y la lectura del stream del turno, cuyo `stream_url` viene
en el acuse de `POST /v1/agent/turns`.

Esa segunda ruta admite **dos credenciales distintas** para la misma autorización: su clave de API con
`jobs:read`, y el token de sesión de nuestra consola con su propio permiso de lectura de conversación. El
criterio es el mismo en los dos casos —quien paga el turno puede leer su respuesta— y un trabajo de otra
cuenta responde igual que uno inexistente.

🔴 **Sin `jobs:read`, un turno se paga y no se puede leer, y por eso toda clave nace con él.** La emisión
añade **siempre** `jobs:read` y `agents:read` a lo que usted marque: son las dos lecturas que no gastan
créditos ni cupo, y sin ellas quien integra no puede leer lo que pagó ni descubrir el `agent_id` que las
demás rutas le exigen ([página 9](./09-changelog.md)). La otra cara —la que hay que tener presente al
emitir— es que esas dos **no se pueden dejar fuera**: si su política es conceder el mínimo indispensable,
cuente con ellas dentro y no espere una clave que las excluya. La expansión ocurre **solo al emitir**, así
que una clave emitida antes de que uno de esos dos permisos existiera no lo gana por sí sola: hay que emitir
otra y rotarla.

### `agent:converse` no se concede «por si acaso»

Es la capacidad más cara de la superficie y la única que no se puede deshacer: abrir un turno gasta créditos,
invoca las herramientas de **su** servidor —que pueden escribir en sus sistemas— y ya ocurrió. Concederlo a
una clave emitida para transcribir audio es regalar eso. Es lo contrario de `jobs:read`: ahí lo que faltaba
era una lectura inocua, aquí lo que sobra es una escritura irreversible.

Los permisos de una clave ya emitida **no se editan desde la consola**: si le falta uno, emita otra con lo que
necesita, despliegue y revoque la anterior. Cada entorno admite dos claves activas justo para eso.

## `POST /v1/vision/analysis`

Análisis de una imagen. `multipart/form-data` con un solo archivo.

| Campo | Dónde | Obligatorio | Límite |
|---|---|---|---|
| `file` | parte del multipart | sí | 10 MiB |

**cURL**

```bash
curl -sS -X POST "$ASIXTO_BASE_URL/v1/vision/analysis" \
  -H "Authorization: Bearer $ASIXTO_API_KEY" \
  -H "Idempotency-Key: img-4471" \
  -F "file=@albaran.jpg"
```

**Node.js**

```js
import { openAsBlob } from 'node:fs';

const cuerpo = new FormData();
cuerpo.append('file', await openAsBlob('albaran.jpg'), 'albaran.jpg');

const respuesta = await fetch(`${process.env.ASIXTO_BASE_URL}/v1/vision/analysis`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.ASIXTO_API_KEY}`,
    'Idempotency-Key': 'img-4471',
  },
  body: cuerpo,
});

const sobre = await respuesta.json();
console.log(sobre.job_id, sobre.usage.credits, sobre.result);
```

**Python**

```python
import os
import requests

url = f"{os.environ['ASIXTO_BASE_URL']}/v1/vision/analysis"
cabeceras = {
    "Authorization": f"Bearer {os.environ['ASIXTO_API_KEY']}",
    "Idempotency-Key": "img-4471",
}

archivos = {
    "file": open("albaran.jpg", "rb"),
}

respuesta = requests.post(url, headers=cabeceras, files=archivos, timeout=120)
respuesta.raise_for_status()

sobre = respuesta.json()
print(sobre["job_id"], sobre["usage"]["credits"], sobre["result"])
```

**PHP**

```php
<?php

$url = getenv('ASIXTO_BASE_URL') . '/v1/vision/analysis';
$cabeceras = [
    'Authorization: Bearer ' . getenv('ASIXTO_API_KEY'),
    'Idempotency-Key: img-4471',
];

$cuerpo = [
    'file' => new CURLFile('albaran.jpg'),
];

$ch = curl_init($url);
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => $cabeceras,
    CURLOPT_POSTFIELDS => $cuerpo,
]);

$respuesta = curl_exec($ch);
curl_close($ch);

$sobre = json_decode($respuesta, true);
echo $sobre['job_id'], ' ', $sobre['usage']['credits'], PHP_EOL;
```

**Go**

```go
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"mime/multipart"
	"net/http"
	"os"
)

func main() {
	var cuerpo bytes.Buffer
	formulario := multipart.NewWriter(&cuerpo)
	archivo, _ := os.Open("albaran.jpg")
	parte, _ := formulario.CreateFormFile("file", "albaran.jpg")
	io.Copy(parte, archivo)
	formulario.Close()

	peticion, _ := http.NewRequest("POST", os.Getenv("ASIXTO_BASE_URL")+"/v1/vision/analysis", &cuerpo)
	peticion.Header.Set("Authorization", "Bearer "+os.Getenv("ASIXTO_API_KEY"))
	peticion.Header.Set("Content-Type", formulario.FormDataContentType())
	peticion.Header.Set("Idempotency-Key", "img-4471")

	respuesta, err := http.DefaultClient.Do(peticion)
	if err != nil {
		panic(err)
	}
	defer respuesta.Body.Close()

	var sobre map[string]any
	json.NewDecoder(respuesta.Body).Decode(&sobre)
	fmt.Println(sobre["job_id"], sobre["usage"], sobre["result"])
}
```

`result`: `analysis` (la descripción), `summary` y `degraded`. Si el modelo no pudo leer la imagen, la
operación **no se cobra** y responde el error de proveedor no disponible en vez de un análisis vacío.

## `POST /v1/documents/analysis`

Análisis de un documento que **usted aloja**. Se envía la URL, no el archivo: su almacenamiento ya sabe
servirlo con una URL firmada, y pedirle que nos lo suba sería mover megabytes dos veces.

| Campo | Tipo | Obligatorio | Límite |
|---|---|---|---|
| `document_url` | texto | sí | 1–2048 caracteres |
| `mime_type` | texto | sí | 1–255 caracteres |
| `filename` | texto | sí | 1–255 caracteres |

**cURL**

```bash
curl -sS -X POST "$ASIXTO_BASE_URL/v1/documents/analysis" \
  -H "Authorization: Bearer $ASIXTO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: doc-4471" \
  -d '{"document_url":"https://archivos.suempresa.com/contrato.pdf","mime_type":"application/pdf","filename":"contrato.pdf"}'
```

**Node.js**

```js
const respuesta = await fetch(`${process.env.ASIXTO_BASE_URL}/v1/documents/analysis`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.ASIXTO_API_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': 'doc-4471',
  },
  body: JSON.stringify({
    "document_url": "https://archivos.suempresa.com/contrato.pdf",
    "mime_type": "application/pdf",
    "filename": "contrato.pdf"
  }),
});

const sobre = await respuesta.json();
console.log(sobre.job_id, sobre.usage.credits, sobre.result);
```

**Python**

```python
import os
import requests

url = f"{os.environ['ASIXTO_BASE_URL']}/v1/documents/analysis"
cabeceras = {
    "Authorization": f"Bearer {os.environ['ASIXTO_API_KEY']}",
    "Idempotency-Key": "doc-4471",
}

cuerpo = {
  "document_url": "https://archivos.suempresa.com/contrato.pdf",
  "mime_type": "application/pdf",
  "filename": "contrato.pdf"
}

respuesta = requests.post(url, headers=cabeceras, json=cuerpo, timeout=120)
respuesta.raise_for_status()

sobre = respuesta.json()
print(sobre["job_id"], sobre["usage"]["credits"], sobre["result"])
```

**PHP**

```php
<?php

$url = getenv('ASIXTO_BASE_URL') . '/v1/documents/analysis';
$cabeceras = [
    'Authorization: Bearer ' . getenv('ASIXTO_API_KEY'),
    'Content-Type: application/json',
    'Idempotency-Key: doc-4471',
];

$cuerpo = {
  'document_url': 'https://archivos.suempresa.com/contrato.pdf',
  'mime_type': 'application/pdf',
  'filename': 'contrato.pdf'
};

$ch = curl_init($url);
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => $cabeceras,
    CURLOPT_POSTFIELDS => json_encode($cuerpo),
]);

$respuesta = curl_exec($ch);
curl_close($ch);

$sobre = json_decode($respuesta, true);
echo $sobre['job_id'], ' ', $sobre['usage']['credits'], PHP_EOL;
```

**Go**

```go
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"strings"
)

func main() {
	cuerpo := strings.NewReader(`{"document_url":"https://archivos.suempresa.com/contrato.pdf","mime_type":"application/pdf","filename":"contrato.pdf"}`)

	peticion, _ := http.NewRequest("POST", os.Getenv("ASIXTO_BASE_URL")+"/v1/documents/analysis", cuerpo)
	peticion.Header.Set("Authorization", "Bearer "+os.Getenv("ASIXTO_API_KEY"))
	peticion.Header.Set("Content-Type", "application/json")
	peticion.Header.Set("Idempotency-Key", "doc-4471")

	respuesta, err := http.DefaultClient.Do(peticion)
	if err != nil {
		panic(err)
	}
	defer respuesta.Body.Close()

	var sobre map[string]any
	json.NewDecoder(respuesta.Body).Decode(&sobre)
	fmt.Println(sobre["job_id"], sobre["usage"], sobre["result"])
}
```

`result`: `extracted_text`, `summary` y `degraded`.

Si no podemos descargar su documento —URL firmada caducada, su almacenamiento limitando la tasa, una cadena
de redirecciones que no termina— recibe `422 document_unreachable`, que existe para atribuirle el fallo a
quien lo puede arreglar: la petición está bien escrita y el recurso al que apunta no se pudo procesar.

## `POST /v1/video/analysis`

Análisis de un video que **usted aloja**, igual que el documento: se manda la URL en JSON, no el archivo.

| Campo | Tipo | Obligatorio | Límite |
|---|---|---|---|
| `video_url` | texto | sí | 1–2048 caracteres |
| `mime_type` | texto | sí | 1–255 caracteres, y ha de ser un tipo `video/…` |
| `filename` | texto | sí | 1–255 caracteres |
| `duration_seconds` | entero | no | 1 a 600 segundos |

**cURL**

```bash
curl -sS -X POST "$ASIXTO_BASE_URL/v1/video/analysis" \
  -H "Authorization: Bearer $ASIXTO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: video-4471" \
  -d '{"video_url":"https://archivos.suempresa.com/entrega.mp4","mime_type":"video/mp4","filename":"entrega.mp4","duration_seconds":45}'
```

**Node.js**

```js
const respuesta = await fetch(`${process.env.ASIXTO_BASE_URL}/v1/video/analysis`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.ASIXTO_API_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': 'video-4471',
  },
  body: JSON.stringify({
    "video_url": "https://archivos.suempresa.com/entrega.mp4",
    "mime_type": "video/mp4",
    "filename": "entrega.mp4",
    "duration_seconds": 45
  }),
});

const sobre = await respuesta.json();
console.log(sobre.job_id, sobre.usage.credits, sobre.result);
```

**Python**

```python
import os
import requests

url = f"{os.environ['ASIXTO_BASE_URL']}/v1/video/analysis"
cabeceras = {
    "Authorization": f"Bearer {os.environ['ASIXTO_API_KEY']}",
    "Idempotency-Key": "video-4471",
}

cuerpo = {
  "video_url": "https://archivos.suempresa.com/entrega.mp4",
  "mime_type": "video/mp4",
  "filename": "entrega.mp4",
  "duration_seconds": 45
}

respuesta = requests.post(url, headers=cabeceras, json=cuerpo, timeout=120)
respuesta.raise_for_status()

sobre = respuesta.json()
print(sobre["job_id"], sobre["usage"]["credits"], sobre["result"])
```

**PHP**

```php
<?php

$url = getenv('ASIXTO_BASE_URL') . '/v1/video/analysis';
$cabeceras = [
    'Authorization: Bearer ' . getenv('ASIXTO_API_KEY'),
    'Content-Type: application/json',
    'Idempotency-Key: video-4471',
];

$cuerpo = {
  'video_url': 'https://archivos.suempresa.com/entrega.mp4',
  'mime_type': 'video/mp4',
  'filename': 'entrega.mp4',
  'duration_seconds': 45
};

$ch = curl_init($url);
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => $cabeceras,
    CURLOPT_POSTFIELDS => json_encode($cuerpo),
]);

$respuesta = curl_exec($ch);
curl_close($ch);

$sobre = json_decode($respuesta, true);
echo $sobre['job_id'], ' ', $sobre['usage']['credits'], PHP_EOL;
```

**Go**

```go
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"strings"
)

func main() {
	cuerpo := strings.NewReader(`{"video_url":"https://archivos.suempresa.com/entrega.mp4","mime_type":"video/mp4","filename":"entrega.mp4","duration_seconds":45}`)

	peticion, _ := http.NewRequest("POST", os.Getenv("ASIXTO_BASE_URL")+"/v1/video/analysis", cuerpo)
	peticion.Header.Set("Authorization", "Bearer "+os.Getenv("ASIXTO_API_KEY"))
	peticion.Header.Set("Content-Type", "application/json")
	peticion.Header.Set("Idempotency-Key", "video-4471")

	respuesta, err := http.DefaultClient.Do(peticion)
	if err != nil {
		panic(err)
	}
	defer respuesta.Body.Close()

	var sobre map[string]any
	json.NewDecoder(respuesta.Body).Decode(&sobre)
	fmt.Println(sobre["job_id"], sobre["usage"], sobre["result"])
}
```

`result`: `analysis` (la descripción), `summary` y `frames_analyzed`. Un análisis que no se pudo hacer **no se
cobra**: responde el error en vez de un informe vacío.

`filename` es solo para el informe y el registro: no se usa para resolver nada. `mime_type` que no sea de
video se rechaza, porque esta operación analiza video y la imagen tiene la suya.

### Los dos techos de la entrada

**50 MiB y 600 segundos**, y pasado cualquiera de los dos se rechaza **nombrándolo**, nunca recortando en
silencio: un análisis de parte del video se cobraría igual que el completo.

| Techo | Cómo se comprueba | Qué recibe si se pasa |
|---|---|---|
| 50 MiB | Un sondeo del enlace que usted declara | `413 payload_too_large`, con los bytes que anuncia su origen |
| 600 segundos | La duración que usted declara en `duration_seconds` | `400 validation_error`, que nombra el campo |

`duration_seconds` es opcional porque **la declara usted**: no sostenemos los bytes del video, así que no
podemos medirla por nuestra cuenta. Cuando falta, el techo que acota lo que llega es el de tamaño. Y
declararla de menos no abarata nada, porque el precio no depende de ella.

🔴 **La tarifa es plana: 270 créditos por video** ([página 12](./12-pricing.md)). Un video de diez segundos y
uno de cinco minutos cuestan lo mismo. Por eso la entrada tiene techos y por eso se rechaza en vez de
recortar.

## `POST /v1/audio/transcriptions`

Audio a texto. `multipart/form-data`, y el idioma va en la query.

| Campo | Dónde | Obligatorio | Límite |
|---|---|---|---|
| `file` | parte del multipart | sí | 25 MiB |
| `language` | query | no | ISO 639-1, 2–5 caracteres |

Formatos admitidos: FLAC, MP3, MP4, MPEG, MPGA, M4A, OGG, WAV y WEBM. Otro tipo recibe
`415 unsupported_media_type` con la lista, en vez de fallar más adelante.

**cURL**

```bash
curl -sS -X POST "$ASIXTO_BASE_URL/v1/audio/transcriptions?language=es" \
  -H "Authorization: Bearer $ASIXTO_API_KEY" \
  -H "Idempotency-Key: nota-4471" \
  -F "file=@nota.ogg"
```

**Node.js**

```js
import { openAsBlob } from 'node:fs';

const cuerpo = new FormData();
cuerpo.append('file', await openAsBlob('nota.ogg'), 'nota.ogg');

const respuesta = await fetch(`${process.env.ASIXTO_BASE_URL}/v1/audio/transcriptions?language=es`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.ASIXTO_API_KEY}`,
    'Idempotency-Key': 'nota-4471',
  },
  body: cuerpo,
});

const sobre = await respuesta.json();
console.log(sobre.job_id, sobre.usage.credits, sobre.result);
```

**Python**

```python
import os
import requests

url = f"{os.environ['ASIXTO_BASE_URL']}/v1/audio/transcriptions?language=es"
cabeceras = {
    "Authorization": f"Bearer {os.environ['ASIXTO_API_KEY']}",
    "Idempotency-Key": "nota-4471",
}

archivos = {
    "file": open("nota.ogg", "rb"),
}

respuesta = requests.post(url, headers=cabeceras, files=archivos, timeout=120)
respuesta.raise_for_status()

sobre = respuesta.json()
print(sobre["job_id"], sobre["usage"]["credits"], sobre["result"])
```

**PHP**

```php
<?php

$url = getenv('ASIXTO_BASE_URL') . '/v1/audio/transcriptions?language=es';
$cabeceras = [
    'Authorization: Bearer ' . getenv('ASIXTO_API_KEY'),
    'Idempotency-Key: nota-4471',
];

$cuerpo = [
    'file' => new CURLFile('nota.ogg'),
];

$ch = curl_init($url);
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => $cabeceras,
    CURLOPT_POSTFIELDS => $cuerpo,
]);

$respuesta = curl_exec($ch);
curl_close($ch);

$sobre = json_decode($respuesta, true);
echo $sobre['job_id'], ' ', $sobre['usage']['credits'], PHP_EOL;
```

**Go**

```go
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"mime/multipart"
	"net/http"
	"os"
)

func main() {
	var cuerpo bytes.Buffer
	formulario := multipart.NewWriter(&cuerpo)
	archivo, _ := os.Open("nota.ogg")
	parte, _ := formulario.CreateFormFile("file", "nota.ogg")
	io.Copy(parte, archivo)
	formulario.Close()

	peticion, _ := http.NewRequest("POST", os.Getenv("ASIXTO_BASE_URL")+"/v1/audio/transcriptions?language=es", &cuerpo)
	peticion.Header.Set("Authorization", "Bearer "+os.Getenv("ASIXTO_API_KEY"))
	peticion.Header.Set("Content-Type", formulario.FormDataContentType())
	peticion.Header.Set("Idempotency-Key", "nota-4471")

	respuesta, err := http.DefaultClient.Do(peticion)
	if err != nil {
		panic(err)
	}
	defer respuesta.Body.Close()

	var sobre map[string]any
	json.NewDecoder(respuesta.Body).Decode(&sobre)
	fmt.Println(sobre["job_id"], sobre["usage"], sobre["result"])
}
```

**Mande `language` solo con evidencia real del idioma.** Forzar uno que no es el del audio no falla: el
modelo **traduce**. Medido contra el proveedor — una nota en inglés con `language=es` vuelve en español. Sin
el parámetro se autodetecta, que es el comportamiento correcto por omisión.

**Se cobran 10 segundos como mínimo**, porque es lo que factura el proveedor: una nota de tres segundos
—el caso normal en un chat— cuesta diez. Va en `usage.quantity`, así que se puede conciliar.

`result`: `text` y `filtered_hallucination`. Un texto vacío **con** ese indicador puesto significa que
descartamos lo único que devolvió el proveedor: eso no se cobra. Un texto vacío **sin** el indicador es un
audio sin habla, y eso sí es un resultado.

## `POST /v1/audio/speech`

Texto a voz. **Devuelve el audio como cuerpo binario**, siguiendo la convención de la industria, así que el
sobre viaja en cabeceras.

| Campo | Tipo | Obligatorio | Límite |
|---|---|---|---|
| `text` | texto | sí | 1–5.000 caracteres |
| `voice_id` | texto | sí | 1–100 caracteres |
| `model_id` | texto | no | 1–100 caracteres |
| `language_code` | texto | no | 2–10 caracteres |
| `voice_settings` | objeto | no | se reenvía sin interpretarlo |
| `speed` | número | no | 0,25 a 4 |

**cURL**

```bash
curl -sS -X POST "$ASIXTO_BASE_URL/v1/audio/speech" \
  -H "Authorization: Bearer $ASIXTO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: voz-4471" \
  -d '{"text":"Su pedido llega el martes","voice_id":"es-CO-natural","speed":1}' \
  --output respuesta.mp3 -D cabeceras.txt
```

**Node.js**

```js
import { writeFile } from 'node:fs/promises';

const respuesta = await fetch(`${process.env.ASIXTO_BASE_URL}/v1/audio/speech`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.ASIXTO_API_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': 'voz-4471',
  },
  body: JSON.stringify({
    "text": "Su pedido llega el martes",
    "voice_id": "es-CO-natural",
    "speed": 1
  }),
});

// El cuerpo es el audio: el consumo viaja en las cabeceras `X-Asixto-*`.
console.log(respuesta.headers.get('X-Asixto-Usage-Credits'));
await writeFile('respuesta.mp3', Buffer.from(await respuesta.arrayBuffer()));
```

**Python**

```python
import os
import requests

url = f"{os.environ['ASIXTO_BASE_URL']}/v1/audio/speech"
cabeceras = {
    "Authorization": f"Bearer {os.environ['ASIXTO_API_KEY']}",
    "Idempotency-Key": "voz-4471",
}

cuerpo = {
  "text": "Su pedido llega el martes",
  "voice_id": "es-CO-natural",
  "speed": 1
}

respuesta = requests.post(url, headers=cabeceras, json=cuerpo, timeout=120)
respuesta.raise_for_status()

# El cuerpo es el audio: el consumo viaja en las cabeceras `X-Asixto-*`.
print(respuesta.headers["X-Asixto-Usage-Credits"])
open("respuesta.mp3", "wb").write(respuesta.content)
```

**PHP**

```php
<?php

$url = getenv('ASIXTO_BASE_URL') . '/v1/audio/speech';
$cabeceras = [
    'Authorization: Bearer ' . getenv('ASIXTO_API_KEY'),
    'Content-Type: application/json',
    'Idempotency-Key: voz-4471',
];

$cuerpo = {
  'text': 'Su pedido llega el martes',
  'voice_id': 'es-CO-natural',
  'speed': 1
};

$ch = curl_init($url);
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => $cabeceras,
    CURLOPT_POSTFIELDS => json_encode($cuerpo),
    CURLOPT_HEADER => true,
]);

$respuesta = curl_exec($ch);
curl_close($ch);

// El cuerpo es el audio y las cabeceras traen el consumo (`X-Asixto-*`).
file_put_contents('respuesta.mp3', $respuesta);
```

**Go**

```go
package main

import (
	"fmt"
	"io"
	"net/http"
	"os"
	"strings"
)

func main() {
	cuerpo := strings.NewReader(`{"text":"Su pedido llega el martes","voice_id":"es-CO-natural","speed":1}`)

	peticion, _ := http.NewRequest("POST", os.Getenv("ASIXTO_BASE_URL")+"/v1/audio/speech", cuerpo)
	peticion.Header.Set("Authorization", "Bearer "+os.Getenv("ASIXTO_API_KEY"))
	peticion.Header.Set("Content-Type", "application/json")
	peticion.Header.Set("Idempotency-Key", "voz-4471")

	respuesta, err := http.DefaultClient.Do(peticion)
	if err != nil {
		panic(err)
	}
	defer respuesta.Body.Close()

	// El cuerpo es el audio: el consumo viaja en las cabeceras `X-Asixto-*`.
	fmt.Println(respuesta.Header.Get("X-Asixto-Usage-Credits"))
	archivoSalida, _ := os.Create("respuesta.mp3")
	io.Copy(archivoSalida, respuesta.Body)
}
```

| Cabecera | Qué trae |
|---|---|
| `X-Asixto-Request-Id` | lo mismo que `request_id` |
| `X-Asixto-Job-Id` | lo mismo que `job_id` |
| `X-Asixto-Usage-Operation` | lo mismo que `usage.operation` |
| `X-Asixto-Usage-Unit` | lo mismo que `usage.unit` |
| `X-Asixto-Usage-Quantity` | lo mismo que `usage.quantity` |
| `X-Asixto-Usage-Credits` | lo mismo que `usage.credits` |
| `X-Asixto-Audio-Duration-Ms` | duración del audio |
| `X-Asixto-Chars-Input` | caracteres que usted envió |
| `X-Asixto-Chars-Normalized` | en qué se convirtió su texto |
| `X-Asixto-Chars-Billed` | caracteres cobrados |

Las tres últimas están para que entienda por qué se le cobran más caracteres de los que escribió: el
proveedor cobra el texto **normalizado**, y la normalización expande —«Dr.» se dice «Doctor», «999» se dice
«novecientos noventa y nueve»—.

🔴 **Es la única operación cuyo resultado no se puede reenviar.** El resultado es el audio, y guardarlo
significaría megabytes por petición y una copia durable de lo que usted dictó. Repetir su clave de
idempotencia recibe `409 result_not_replayable` y **el cobro no se duplica**: si necesita el audio otra vez,
pida la síntesis con una clave nueva.

## `POST /v1/text/sentiment`

| Campo | Tipo | Obligatorio | Límite |
|---|---|---|---|
| `text` | texto | sí | 1–10.000 caracteres |
| `customer_id` | texto | no | 1–128 caracteres |
| `channel` | texto | no | 1–64 caracteres |
| `region_id` | texto | no | 1–64 caracteres |

**cURL**

```bash
curl -sS -X POST "$ASIXTO_BASE_URL/v1/text/sentiment" \
  -H "Authorization: Bearer $ASIXTO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: sent-4471" \
  -d '{"text":"Llevo tres días esperando y nadie me responde.","customer_id":"cliente-12345"}'
```

**Node.js**

```js
const respuesta = await fetch(`${process.env.ASIXTO_BASE_URL}/v1/text/sentiment`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.ASIXTO_API_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': 'sent-4471',
  },
  body: JSON.stringify({
    "text": "Llevo tres días esperando y nadie me responde.",
    "customer_id": "cliente-12345"
  }),
});

const sobre = await respuesta.json();
console.log(sobre.job_id, sobre.usage.credits, sobre.result);
```

**Python**

```python
import os
import requests

url = f"{os.environ['ASIXTO_BASE_URL']}/v1/text/sentiment"
cabeceras = {
    "Authorization": f"Bearer {os.environ['ASIXTO_API_KEY']}",
    "Idempotency-Key": "sent-4471",
}

cuerpo = {
  "text": "Llevo tres días esperando y nadie me responde.",
  "customer_id": "cliente-12345"
}

respuesta = requests.post(url, headers=cabeceras, json=cuerpo, timeout=120)
respuesta.raise_for_status()

sobre = respuesta.json()
print(sobre["job_id"], sobre["usage"]["credits"], sobre["result"])
```

**PHP**

```php
<?php

$url = getenv('ASIXTO_BASE_URL') . '/v1/text/sentiment';
$cabeceras = [
    'Authorization: Bearer ' . getenv('ASIXTO_API_KEY'),
    'Content-Type: application/json',
    'Idempotency-Key: sent-4471',
];

$cuerpo = {
  'text': 'Llevo tres días esperando y nadie me responde.',
  'customer_id': 'cliente-12345'
};

$ch = curl_init($url);
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => $cabeceras,
    CURLOPT_POSTFIELDS => json_encode($cuerpo),
]);

$respuesta = curl_exec($ch);
curl_close($ch);

$sobre = json_decode($respuesta, true);
echo $sobre['job_id'], ' ', $sobre['usage']['credits'], PHP_EOL;
```

**Go**

```go
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"strings"
)

func main() {
	cuerpo := strings.NewReader(`{"text":"Llevo tres días esperando y nadie me responde.","customer_id":"cliente-12345"}`)

	peticion, _ := http.NewRequest("POST", os.Getenv("ASIXTO_BASE_URL")+"/v1/text/sentiment", cuerpo)
	peticion.Header.Set("Authorization", "Bearer "+os.Getenv("ASIXTO_API_KEY"))
	peticion.Header.Set("Content-Type", "application/json")
	peticion.Header.Set("Idempotency-Key", "sent-4471")

	respuesta, err := http.DefaultClient.Do(peticion)
	if err != nil {
		panic(err)
	}
	defer respuesta.Body.Close()

	var sobre map[string]any
	json.NewDecoder(respuesta.Body).Decode(&sobre)
	fmt.Println(sobre["job_id"], sobre["usage"], sobre["result"])
}
```

`result`: `sentiment`, `score` y `source`. `customer_id` es su identificador y es opaco para nosotros: se
usa para la tendencia del sentimiento de ese cliente, nunca para resolver nada de nuestra base.

## `POST /v1/calls` · asíncrona

Origina una llamada de voz con su agente. Responde `202` con un acuse: la conversación todavía no ha
empezado.

| Campo | Tipo | Obligatorio | Límite |
|---|---|---|---|
| `to` | texto | sí | E.164 con `+` y país |
| `agent_id` | texto | sí | identificador de un agente de su cuenta |
| `max_duration_seconds` | entero | no | 1 a 900; sin él, 300 |
| `topic` | texto | no | hasta 200 caracteres |
| `variables` | objeto | no | ver [contexto por petición](#el-contexto-por-peticion-variables-y-metadata) |
| `metadata` | objeto | no | ídem |

**cURL**

```bash
curl -sS -X POST "$ASIXTO_BASE_URL/v1/calls" \
  -H "Authorization: Bearer $ASIXTO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: llamada-4471" \
  -d '{"to":"+573001112233","agent_id":"a4f1c2e0-6d3b-4e7a-9c21-8f0e5b7d4a13","max_duration_seconds":300,"topic":"cobro de la factura 4471"}'
```

**Node.js**

```js
const respuesta = await fetch(`${process.env.ASIXTO_BASE_URL}/v1/calls`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.ASIXTO_API_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': 'llamada-4471',
  },
  body: JSON.stringify({
    "to": "+573001112233",
    "agent_id": "a4f1c2e0-6d3b-4e7a-9c21-8f0e5b7d4a13",
    "max_duration_seconds": 300,
    "topic": "cobro de la factura 4471"
  }),
});

const sobre = await respuesta.json();
console.log(sobre.job_id, sobre.usage.credits, sobre.result);
```

**Python**

```python
import os
import requests

url = f"{os.environ['ASIXTO_BASE_URL']}/v1/calls"
cabeceras = {
    "Authorization": f"Bearer {os.environ['ASIXTO_API_KEY']}",
    "Idempotency-Key": "llamada-4471",
}

cuerpo = {
  "to": "+573001112233",
  "agent_id": "a4f1c2e0-6d3b-4e7a-9c21-8f0e5b7d4a13",
  "max_duration_seconds": 300,
  "topic": "cobro de la factura 4471"
}

respuesta = requests.post(url, headers=cabeceras, json=cuerpo, timeout=120)
respuesta.raise_for_status()

sobre = respuesta.json()
print(sobre["job_id"], sobre["usage"]["credits"], sobre["result"])
```

**PHP**

```php
<?php

$url = getenv('ASIXTO_BASE_URL') . '/v1/calls';
$cabeceras = [
    'Authorization: Bearer ' . getenv('ASIXTO_API_KEY'),
    'Content-Type: application/json',
    'Idempotency-Key: llamada-4471',
];

$cuerpo = {
  'to': '+573001112233',
  'agent_id': 'a4f1c2e0-6d3b-4e7a-9c21-8f0e5b7d4a13',
  'max_duration_seconds': 300,
  'topic': 'cobro de la factura 4471'
};

$ch = curl_init($url);
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => $cabeceras,
    CURLOPT_POSTFIELDS => json_encode($cuerpo),
]);

$respuesta = curl_exec($ch);
curl_close($ch);

$sobre = json_decode($respuesta, true);
echo $sobre['job_id'], ' ', $sobre['usage']['credits'], PHP_EOL;
```

**Go**

```go
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"strings"
)

func main() {
	cuerpo := strings.NewReader(`{"to":"+573001112233","agent_id":"a4f1c2e0-6d3b-4e7a-9c21-8f0e5b7d4a13","max_duration_seconds":300,"topic":"cobro de la factura 4471"}`)

	peticion, _ := http.NewRequest("POST", os.Getenv("ASIXTO_BASE_URL")+"/v1/calls", cuerpo)
	peticion.Header.Set("Authorization", "Bearer "+os.Getenv("ASIXTO_API_KEY"))
	peticion.Header.Set("Content-Type", "application/json")
	peticion.Header.Set("Idempotency-Key", "llamada-4471")

	respuesta, err := http.DefaultClient.Do(peticion)
	if err != nil {
		panic(err)
	}
	defer respuesta.Body.Close()

	var sobre map[string]any
	json.NewDecoder(respuesta.Body).Decode(&sobre)
	fmt.Println(sobre["job_id"], sobre["usage"], sobre["result"])
}
```

Acuse: `call_id`, `status` (`queued`), `max_duration_seconds`, `authorized_minutes` y `from`.

**`max_duration_seconds` del acuse puede ser MENOR que el que pidió**: es la forma de saber, antes de que
suene el teléfono, que su saldo solo paga parte de la conversación. La duración fuera de rango **se
rechaza**, no se recorta en silencio, porque es lo que decide cuántos créditos se reservan.

`from` es el número que verá el destinatario. Con `ak_test_` es **nuestro** número compartido y `to` tiene que
ser uno de sus números de prueba verificados (si no, `409 test_destination_not_verified`). Con `ak_live_` es
**el suyo**, y sin él listo la llamada no sale: `409 phone_number_required`, `409 phone_number_in_review` o
`402 phone_number_suspended`. Qué hacer en cada caso, en la [página 14](./14-api-quickstart.md).

Si la conducta de pruebas transfiere a un número que ya no está verificado, la llamada sale **sin
transferencia** y el acuse lo dice en `warnings`:

```json
{ "…": "…", "warnings": { "ignored_variables": [], "transfer_disabled": { "reason": "test_destination_not_verified" } } }
```

Repetir la petición con la misma `Idempotency-Key` **no repite el acuse**. Mientras la llamada vive, su
clave responde `409 request_in_flight`, sin `job_id`, y su `Retry-After` es el tiempo máximo que le queda a
la llamada (la duración autorizada que resta más un margen de cierre), no unos segundos. Cuando cuelga,
devuelve el resultado guardado con el `job_id` original y **los mismos `warnings` del acuse**: `call_id`,
`status` (`completed`), `duration_seconds` y `billed_minutes`. Cómo recuperar el `job_id` si perdió el acuse, en la
[página 17](./17-jobs.md#si-lo-que-perdio-es-el-acuse-de-una-llamada).

## `POST /v1/agent/turns` · asíncrona

Un turno de conversación con su agente. Responde `202` con la ruta por la que se lee la respuesta mientras
se genera.

| Campo | Tipo | Obligatorio | Límite |
|---|---|---|---|
| `message` | texto | sí | 1–20.000 caracteres |
| `channel` | texto | sí | uno de los canales admitidos |
| `agent_id` | texto | sí | identificador de un agente de su cuenta |
| `conversation_external_id` | texto | no | 1–128 caracteres |
| `history` | lista | no | hasta 40 mensajes y 20.000 caracteres sumados |
| `customer` | objeto | no | ver abajo |
| `variables` | objeto | no | ver [contexto por petición](#el-contexto-por-peticion-variables-y-metadata) |
| `metadata` | objeto | no | ídem |

### Canales admitidos

Son estos nueve: `whatsapp`, `facebook_messenger`, `instagram`, `telegram`, `email`, `sms`, `voice`,
`chatbot_web` y `groups`. Van en minúsculas y **tal cual** —WhatsApp no vale—, y cualquier valor fuera de esa
lista recibe `400 validation_error` con ella.

De dónde viene el mensaje es una **declaración suya**, no un canal que nosotros conectemos: no mandamos nada
por él. Lo que hace es cambiar cómo se le habla al cliente final —un correo no se redacta como un mensaje de
chat—, así que declare el real.

Cada mensaje de `history` lleva `role` (`customer` o `agent`), `content` y un `timestamp` opcional en ISO
8601. **El rol es obligatorio**: sin él, lo que dijo el agente se leería como algo que dijo el cliente y el
modelo razonaría sobre una conversación que nunca ocurrió. El orden lo da la lista, no la fecha.

`customer` acepta `external_id` (1–128), `name` (1–200), `phone` (1–32) y `email` (1–320). Son datos suyos y
opacos para nosotros: se le pasan al agente para que sepa con quién habla.

🔴 **`conversation_external_id` y `history` son excluyentes.** Dos fuentes para la misma conversación es una
ambigüedad que nadie puede resolver bien, y la que perdiera se descartaría en silencio.

- **Con `conversation_external_id`**: nosotros mantenemos la sesión. Usted manda solo el mensaje nuevo y la
  nombra con **su** identificador, así que el primer turno ya puede continuar una conversación sin una ida y
  vuelta previa. La clave es única por cuenta, así que nadie más ve la suya. La ventana vive 24 h de
  inactividad ([página 13](./13-data.md)).
- **Con `history`**: usted lleva la conversación y la envía en cada turno.

**cURL**

```bash
curl -sS -X POST "$ASIXTO_BASE_URL/v1/agent/turns" \
  -H "Authorization: Bearer $ASIXTO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: turno-4471" \
  -d '{"message":"¿Cuál es el estado de mi pedido #12345?","channel":"whatsapp","agent_id":"a4f1c2e0-6d3b-4e7a-9c21-8f0e5b7d4a13","conversation_external_id":"cliente-12345"}'
```

**Node.js**

```js
const respuesta = await fetch(`${process.env.ASIXTO_BASE_URL}/v1/agent/turns`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.ASIXTO_API_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': 'turno-4471',
  },
  body: JSON.stringify({
    "message": "¿Cuál es el estado de mi pedido #12345?",
    "channel": "whatsapp",
    "agent_id": "a4f1c2e0-6d3b-4e7a-9c21-8f0e5b7d4a13",
    "conversation_external_id": "cliente-12345"
  }),
});

const sobre = await respuesta.json();
console.log(sobre.job_id, sobre.usage.credits, sobre.result);
```

**Python**

```python
import os
import requests

url = f"{os.environ['ASIXTO_BASE_URL']}/v1/agent/turns"
cabeceras = {
    "Authorization": f"Bearer {os.environ['ASIXTO_API_KEY']}",
    "Idempotency-Key": "turno-4471",
}

cuerpo = {
  "message": "¿Cuál es el estado de mi pedido #12345?",
  "channel": "whatsapp",
  "agent_id": "a4f1c2e0-6d3b-4e7a-9c21-8f0e5b7d4a13",
  "conversation_external_id": "cliente-12345"
}

respuesta = requests.post(url, headers=cabeceras, json=cuerpo, timeout=120)
respuesta.raise_for_status()

sobre = respuesta.json()
print(sobre["job_id"], sobre["usage"]["credits"], sobre["result"])
```

**PHP**

```php
<?php

$url = getenv('ASIXTO_BASE_URL') . '/v1/agent/turns';
$cabeceras = [
    'Authorization: Bearer ' . getenv('ASIXTO_API_KEY'),
    'Content-Type: application/json',
    'Idempotency-Key: turno-4471',
];

$cuerpo = {
  'message': '¿Cuál es el estado de mi pedido #12345?',
  'channel': 'whatsapp',
  'agent_id': 'a4f1c2e0-6d3b-4e7a-9c21-8f0e5b7d4a13',
  'conversation_external_id': 'cliente-12345'
};

$ch = curl_init($url);
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => $cabeceras,
    CURLOPT_POSTFIELDS => json_encode($cuerpo),
]);

$respuesta = curl_exec($ch);
curl_close($ch);

$sobre = json_decode($respuesta, true);
echo $sobre['job_id'], ' ', $sobre['usage']['credits'], PHP_EOL;
```

**Go**

```go
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"strings"
)

func main() {
	cuerpo := strings.NewReader(`{"message":"¿Cuál es el estado de mi pedido #12345?","channel":"whatsapp","agent_id":"a4f1c2e0-6d3b-4e7a-9c21-8f0e5b7d4a13","conversation_external_id":"cliente-12345"}`)

	peticion, _ := http.NewRequest("POST", os.Getenv("ASIXTO_BASE_URL")+"/v1/agent/turns", cuerpo)
	peticion.Header.Set("Authorization", "Bearer "+os.Getenv("ASIXTO_API_KEY"))
	peticion.Header.Set("Content-Type", "application/json")
	peticion.Header.Set("Idempotency-Key", "turno-4471")

	respuesta, err := http.DefaultClient.Do(peticion)
	if err != nil {
		panic(err)
	}
	defer respuesta.Body.Close()

	var sobre map[string]any
	json.NewDecoder(respuesta.Body).Decode(&sobre)
	fmt.Println(sobre["job_id"], sobre["usage"], sobre["result"])
}
```

Acuse: `stream_id`, `status` y `stream_url`. `stream_url` es la ruta por la que se lee la respuesta mientras
se genera, y su contrato —eventos, forma de cada trama y reanudación— está en la
[página 19](./19-api-stream.md).

El turno se cobra **por respuesta generada**, con un precio único ([página 12](./12-pricing.md)). Leer la
respuesta no cuesta nada, ni la primera vez ni al reanudar: el cobro sigue a la generación, no a la
entrega. Si su cliente se desconecta a mitad, el turno cuesta lo mismo.

## El contexto por petición: `variables` y `metadata`

Las dos surten a `POST /v1/calls` y a `POST /v1/agent/turns`, con la MISMA forma y los mismos techos, y
significan cosas opuestas:

| | `variables` | `metadata` |
|---|---|---|
| ¿La ve el agente? | **Sí**: rellena los huecos de su configuración y las dice | **No**, nunca |
| ¿Vuelve en el webhook? | No | **Sí**, en `data.metadata` |
| Para qué es | personalizar lo que se dice | conciliar con su propio sistema |

Los techos son los mismos para las dos: hasta **20 claves**, cada clave en minúsculas, dígitos y `_`
empezando por letra y hasta 40 caracteres (`loan_id`, `nombre_cliente`), cada valor de **hasta 512
caracteres** y el objeto entero **hasta 4 KB**. Los valores van en texto, número o booleano —se guardan como
texto— y **no admiten objetos ni listas dentro**: una clave anidada recibe `400 validation_error` que la
nombra, en vez de descartarse en silencio.

### `variables`: los huecos de la configuración de su agente

En la consola, el saludo y el mensaje de apertura de un agente pueden llevar huecos con la forma
`{{clave}}`. Lo que manda en `variables` es lo que los rellena, en esta petición y solo en esta:

```json
{ "to": "+573001112233", "agent_id": "…", "variables": { "nombre_cliente": "Ana", "monto": "1.250.000" } }
```

Con el saludo `Hola {{nombre_cliente}}, le llamo por su saldo de {{monto}}`, el agente dice
`Hola Ana, le llamo por su saldo de 1.250.000`.

Las dos reglas que le importan, y son opuestas a propósito:

- 🔴 **Falta una que el agente declara ⇒ `400 validation_error` que la nombra.** No se deja el hueco: un
  agente leyendo `{{monto}}` en voz alta a su cliente es peor que un rechazo que usted ve en su log.
- **Sobra una que ninguna plantilla usa ⇒ la petición SE ACEPTA y se lo decimos.** La respuesta trae
  `warnings.ignored_variables` con esas claves. Es así porque el texto del agente se edita en la consola y no
  puede tumbarle su integración cada vez que alguien reescribe una frase — pero tampoco puede quedarse
  callado, o usted daría por hecho que su variable actuó.

```json
{ "request_id": "req_…", "job_id": "job_…", "usage": { "…": "…" }, "result": { "…": "…" },
  "warnings": { "ignored_variables": ["descuento"] } }
```

**`warnings` no viene si no hay nada que avisar**: la clave está ausente, no vacía. Trate su presencia como
la señal, no su contenido. Cuando viene, `ignored_variables` está siempre (vacía si no sobró ninguna) y, en
`POST /v1/calls`, puede venir además `transfer_disabled` (ver la operación, arriba). Qué trae el replay
de la clave depende de la superficie:

- **`POST /v1/calls`**: los mismos `warnings` que el acuse original, porque se guardan con el trabajo al admitir
  la llamada.
- **Turno del agente**: se recalculan con el agente tal como está al repetir. Con las mismas `variables` y el
  mismo texto del agente salen iguales; si alguien editó sus plantillas en la consola entre los dos intentos,
  `ignored_variables` puede cambiar.

### `metadata`: sus identificadores, de ida y de vuelta

Es opaco para nosotros y para el agente: no entra en el prompt, el modelo no lo ve y nadie lo dice. Es el
sitio de su `loan_id`, su `case_id` o su número de expediente. Vuelve **tal cual** en el webhook del cierre,
dentro de `data.metadata` ([página 11](./11-webhooks.md)), y con eso cuadra el resultado contra su sistema
sin guardar nuestro `job_id` en ningún sitio.

Vuelve también cuando el trabajo **vence sin cierre** (`job.expired`), que es justo el caso en el que le hace
más falta.

### Idempotencia: una clave identifica una operación, no una plantilla

Si repite una `Idempotency-Key` con **otras `variables`**, la petición se rechaza con
`409 idempotency_key_reused` en vez de devolverle el resultado de la primera. Reintentar la misma petición
—mismas variables— sigue siendo idempotente y le devuelve el mismo trabajo. Cambiar solo el `metadata` **no**
cuenta como otra operación: es un dato suyo de conciliación, no parte de lo que pidió.

## Lo que el Gateway rellena y usted no puede escribir

En el turno, la identidad de su cuenta, la configuración del agente y la credencial con la que validamos
las herramientas las pone el Gateway. Un cuerpo que intente escribir cualquiera de esas casillas **se
rechaza** en vez de ignorarse: ignorarlo dejaría al integrador creyendo que configuró algo.

Elegir **entre** sus agentes es suyo. Configurarlos se hace en la consola, y es el producto.

---

<!-- fuente: https://gateway.asixto.com/docs/16-api-errors.md -->

# 16 · Errores de la API

Este es el contrato de error de `/v1`. **No es el mismo vocabulario que el de su servidor de
herramientas**: los seis valores que el agente interpreta están en la [página 06](./06-errors.md) y
significan «qué hace el agente». Aquí se habla de transporte, y los dos vocabularios no se mezclan a
propósito — por eso el «no existe» de esta superficie se llama `resource_not_found` y no `NOT_FOUND`.

## La forma, siempre igual

```json
{
  "error": {
    "code": "insufficient_credits",
    "message": "El saldo de créditos de su cuenta no cubre esta operación.",
    "request_id": "req_22398948bdff3f425bdeb7da",
    "doc_url": "https://asixto.com/docs/gateway/errors#insufficient_credits"
  }
}
```

**Programe contra `code`.** Es lo único estable: el estado HTTP significa cosas distintas según el cliente
que use, el texto se reescribe y el idioma se traduce. Renombrar un código sería un cambio incompatible y
pasa por la política de la [página 09](./09-changelog.md).

`request_id` es lo que hay que citar en cualquier incidente. `doc_url` apunta a la explicación del código
concreto.

## Dos reglas que puede dar por buenas

**Un fallo de negocio nunca llega como `5xx`.** Si recibe un `5xx`, el problema es nuestro: o un defecto de
programación, o un servicio del que dependemos. No hay ningún caso en el que su saldo, su cupo, su
credencial o su cuerpo produzcan un `5xx`.

**Los errores temporales traen `Retry-After` en segundos.** Si no lo trae, esperar no cambia nada: hay que
arreglar algo.

## Los treinta y cuatro códigos, y qué hacer con cada uno

Son **34**, agrupados por quién lo arregla. La última columna no es «¿reintentar?»: es la acción concreta, que
es lo único que su código necesita decidir al recibirlo. Cuando la acción es esperar, el plazo llega en
`Retry-After` y no hay que calcularlo.

## Credencial y permisos

| Código | HTTP | Qué pasó | Qué hacer |
|---|---|---|---|
| `authentication_required` | 401 | Falta la credencial o no viene como `Authorization: Bearer …` | Mande la cabecera con esa forma exacta. Reintentar sin cambiarla no sirve |
| `invalid_api_key` | 401 | La clave no existe, está revocada o caducó | Compruebe en la consola que la clave sigue viva; si no, emita otra. No reintente |
| `ip_not_allowed` | 403 | El origen de la petición no está en la lista de su clave | Añada ese origen a la lista de la clave, o llame desde uno que ya esté |
| `insufficient_scope` | 403 | Su clave no tiene el permiso de esta operación | Lea `WWW-Authenticate`: dice cuál faltaba. Emita una clave que lo lleve ([página 15](./15-api-reference.md)) |
| `gateway_not_enabled` | 403 | Su cuenta no tiene el módulo, no tiene cupo en ese entorno o su suscripción está cortada por impago (en los dos entornos: `test` llega al operador telefónico) | Pídanos el alta de **ese** entorno: el cupo es por entorno ([página 18](./18-api-limits.md)); si es el pago, regularícelo en la consola. La preparación de la consola lo muestra en «Cuenta habilitada». No reintente |
| `permission_denied` | 403 | Prohibido por una regla que no es de permiso ni de origen | Escálelo con el `request_id`: no lo arregla ni la clave ni el origen |

Los tres motivos de `invalid_api_key` **no se distinguen a propósito**: decir cuál de los tres es ayudaría a
quien está probando claves. Un `insufficient_scope` declara en `WWW-Authenticate` qué permiso hacía falta.

## Pago y cupo

| Código | HTTP | Qué pasó | Qué hacer |
|---|---|---|---|
| `insufficient_credits` | 402 | Su saldo no cubre la operación, o el excedente está encendido y su cuenta no tiene medio de pago verificado | Recargue créditos, o agregue un medio de pago para que el excedente cuente, y reintente. Subir el tope de gasto no lo arregla |
| `spending_cap_reached` | 402 | Su tope de gasto por excedente no admite este importe | Suba el tope de gasto de la cuenta. Recargar **no** lo arregla |
| `period_quota_exhausted` | 429 | El techo de créditos del periodo está agotado | Espere lo que dice `Retry-After`, o pídanos un techo mayor para el periodo |
| `concurrency_limit_reached` | 429 | Su techo de peticiones simultáneas está lleno | Espere `Retry-After` y baje sus peticiones en vuelo |
| `call_concurrency_limit_reached` | 429 | Su techo de **llamadas** simultáneas está lleno | Espere a que cuelgue una llamada. Espaciar peticiones no libera este techo |
| `request_exceeds_period_quota` | 409 | La petición mínima de la operación no cabe en el techo del periodo **entero** | Pídanos un techo mayor para el periodo. Esperar no lo arregla: no trae `Retry-After` |

`request_exceeds_period_quota` no es `period_quota_exhausted`: aquí el techo no se ha gastado, es que esta
operación no cabe en él ni con el periodo vacío. Por eso es `409` y no `429`, y no trae plazo de espera. Los
dos solo pueden llegar si su cuenta tiene un techo de créditos por periodo: una cuenta de la beta nace **sin
techo** ([página 18](./18-api-limits.md)), y entonces lo que manda es el saldo.

🔴 **El catálogo tiene tres `402`, y los dos de esta tabla piden acciones opuestas: por eso son dos códigos.**
El tercero, `phone_number_suspended`, es del número propio y está en la tabla siguiente. Con `insufficient_credits` hay que
recargar; con `spending_cap_reached`, **recargar no sirve** —el consumo por excedente sigue bloqueado en el
mismo punto— y lo que hay que subir es el límite. Un cliente que solo mire el estado HTTP no distinguirá los
dos; el que mire el `code`, sí. Ninguno de los dos lleva `Retry-After`: no es un plazo que se cumpla solo.

**Los dos techos de concurrencia también son recursos distintos.** El de llamadas puede estar lleno mientras
el de peticiones está vacío, y la respuesta correcta es opuesta: espaciar peticiones, o esperar a que
cuelgue una llamada.

🔴 **Una clave `ak_test_` también recibe los dos `402` de saldo y de tope.** El entorno de pruebas ejecuta de verdad contra los
mismos proveedores, así que **también consume créditos** y también depende de su saldo y de su tope de gasto
([página 14](./14-api-quickstart.md)). Pruebas y producción gastan del **mismo** saldo y ninguno tiene un techo
de créditos propio: lo que acota un bucle de pruebas es su tope de gasto, no un cupo del entorno.

## Destino de prueba y número propio

| Código | HTTP | Qué pasó | Qué hacer |
|---|---|---|---|
| `test_destination_not_verified` | 409 | Con una clave `ak_test_`, el destino no es uno de sus números de prueba verificados | Registre y verifique ese número como destino de prueba en la consola, o llame a uno que ya lo esté. No se le cobra |
| `phone_number_required` | 409 | En `live`, su cuenta no tiene número propio, o su expediente falta, está incompleto o fue rechazado | Compre su número, o cree, complete o corrija el expediente en la consola. Reintentar no cambia nada |
| `phone_number_in_review` | 409 | En `live`, su número o su expediente está en provisión o en revisión | No reintente en bucle: consulte el estado del número en la consola. Cuando esté aprobado, la misma petición funciona. No trae `Retry-After`: no es un plazo que podamos prometer |
| `phone_number_suspended` | 402 | En `live`, su número está suspendido porque venció la gracia del pago de su cuota | Póngase al día con el pago de la cuenta; el número vuelve a servicio sin cambiar de clave |

Ninguno es `403`: su credencial sirve en los cuatro. Los tres `409` dicen que lo que no está listo es el
destino o el número, y `phone_number_suspended` es `402` como el saldo porque lo arregla un pago.

Los cuatro salen **antes** de abrir el trabajo: no se cobran y no consumen su `Idempotency-Key`, así que,
arreglada la causa, reintente con la **misma** clave. En `live` ya no hay número de Asixto de reserva: sin
número propio listo, la llamada no sale ([página 09](./09-changelog.md)). Qué poner en `to` y cómo dar de
alta un número de prueba, en la [página 14](./14-api-quickstart.md).

## Su petición

| Código | HTTP | Qué pasó | Qué hacer |
|---|---|---|---|
| `validation_error` | 400 | El cuerpo no cumple el contrato. El mensaje dice qué campo | Corrija ese campo. Reintentar el mismo cuerpo da el mismo error |
| `payload_too_large` | 413 | El cuerpo o el archivo supera el techo | Recorte o parta el contenido bajo el techo de la operación ([página 15](./15-api-reference.md)) |
| `unsupported_media_type` | 415 | El tipo o el formato del archivo no se admite | Convierta el archivo a uno de los formatos que lista el mensaje |
| `document_unreachable` | 422 | No pudimos descargar el documento que declaró | Con `Retry-After`, espere y reintente; sin él, arregle el enlace (URL firmada caducada, redirecciones) |
| `resource_not_found` | 404 | La ruta o el trabajo no existen | Revise la ruta y el `job_id`. Un trabajo de otra cuenta responde igual |
| `method_not_allowed` | 405 | La ruta existe pero no con ese método | Use el verbo que publica la referencia. Hoy **no tiene emisor**: no programe contra él |
| `rate_limited` | 429 | Techo de tasa de la plataforma, no su cupo | Espere `Retry-After`. No hay saldo que leer: el cupo es compartido |
| `key_rate_limited` | 429 | Ritmo de **su clave**; el saldo viaja en `RateLimit` | Espere `Retry-After` y regúlese con `RateLimit` ([página 18](./18-api-limits.md)) |
| `request_rejected` | 400 | Rechazo sin causa más fina | Revise la petición; si no encuentra la causa, escálela con el `request_id` |

`document_unreachable` es un `422` y no un `400` ni un `503` porque el fallo es del recurso, no de su
petición ni de nuestro servicio: su URL firmada caducó, su almacenamiento está limitando la tasa o la cadena
de redirecciones no termina. Cuando la causa es transitoria viaja con `Retry-After`, y eso es lo que
distingue «reintente» de «arregle el enlace».

`rate_limited` no es su cupo: es el limitador de la plataforma, y aquí no hay un cupo propio que se libere.
`key_rate_limited` sí lo es —el ritmo de su clave— y su saldo viaja en `RateLimit` ([página 18](./18-api-limits.md)).
Los dos traen `Retry-After`, que es lo que hace el reintento correcto sin mirar nada más.

## Estado del trabajo, de la clave de idempotencia y del recurso

| Código | HTTP | Qué pasó | Qué hacer |
|---|---|---|---|
| `request_in_flight` | 409 | Otra petición con la misma clave sigue en curso | Consulte el trabajo ([página 17](./17-jobs.md)) en vez de reintentar en bucle |
| `idempotency_key_reused` | 409 | Reusó la clave para otra operación, o para la misma con otras `variables` | Use una clave distinta por operación. Nunca la comparta entre dos |
| `result_not_replayable` | 409 | La operación se ejecutó y su resultado no se puede reenviar | Pida la operación con una clave **nueva**. El cobro anterior no se duplica |
| `job_not_completed` | 409 | El trabajo de esa clave venció su plazo sin cerrarse | Pida la operación con una clave **nueva**: no hay resultado que recuperar |
| `agent_outside_schedule` | 409 | El instante de la petición cae fuera del horario del agente | Reintente a partir de `next_window_at`, que viene en el cuerpo del error |

Los cinco son `409` porque el conflicto es con el **estado** del recurso, no con su autorización ni con su
cuerpo. `request_in_flight` sigue el precedente de la industria para una clave en curso.

`agent_outside_schedule` es el único código cuyo cuerpo trae un **quinto campo**, `next_window_at`, con la
apertura de la ventana siguiente en ISO-8601 UTC. Lleva una fecha y no un `Retry-After` porque aquí el plazo
se conoce: lo declara el horario del propio agente, no una avería. Los otros treinta y tres **no** traen el
campo, y la clave se omite en vez de viajar en `null`, así que su capa de transporte no tiene que preverlo.

`result_not_replayable` no es un `404`: el trabajo **existe** y su cobro también; lo que no existe es una
copia de su salida. Hoy solo lo produce la síntesis de voz ([página 15](./15-api-reference.md)).

## Nuestro lado

| Código | HTTP | Qué pasó | Qué hacer |
|---|---|---|---|
| `upstream_unavailable` | 503 | Un servicio del que dependemos no responde | Espere `Retry-After` y reintente; con qué clave depende de la superficie (debajo). No se le cobra |
| `upstream_timeout` | 504 | Aceptó la petición y no respondió a tiempo | La operación pudo ejecutarse. En `POST /v1/calls`, reintente con la **misma** clave; en el resto, con una **nueva** (debajo). En una operación síncrona no se le cobra; en `POST /v1/calls` se cobra solo si nos llega el cierre de la llamada (debajo) |
| `operation_not_priced` | 500 | Defecto nuestro: la operación no tiene precio | Escálelo con el `request_id`. No reintente: no se arregla solo |
| `internal_error` | 500 | Defecto nuestro: excepción no prevista | Escálelo con el `request_id` |

**Un `upstream_unavailable` nunca se cobra, y un `upstream_timeout` tampoco, salvo en `POST /v1/calls`:**
ahí un `504` se cobra si nos llega el cierre de la llamada —la liquida, como a cualquier llamada—; si el trabajo
vence sin él, se le devuelve lo reservado. Un `upstream_unavailable` también es lo que recibe cuando el proveedor
responde pero no entrega lo que se pidió —un análisis degradado, una transcripción descartada, un turno que
solo alcanza a disculparse—: para usted es el mismo hecho, no recibió lo que pidió y no se le cobra
([página 13](./13-data.md)).

**Con qué clave reintentar un `5xx` transitorio depende de dónde lo recibió:**

| Superficie | Reintente con | Por qué |
|---|---|---|
| `POST /v1/calls`, con `503` o con `504` | La **misma** clave, **siempre** | Ver debajo: una clave nueva tras un `504` puede llamar dos veces a la misma persona |
| Una operación síncrona, cuando el `503` es al comprobar su cupo de peticiones simultáneas | La misma o una nueva: **cualquiera** vale | Sale antes de abrir el trabajo, así que la clave no quedó usada |
| Una operación síncrona en cualquier otro caso, y el turno del agente | Una clave **nueva** | El trabajo se cierra con el fallo, y repetir la clave le devuelve el mismo error sin volver a intentarlo ([página 17](./17-jobs.md)) |

Si repite una clave cuyo trabajo ya terminó con un `5xx` transitorio, recibe el mismo código con un mensaje que
le pide una **clave nueva** y sin `Retry-After`: esa clave republica su fallo en cada intento.

**En `POST /v1/calls` el código le dice si la llamada salió:**

- **`503 upstream_unavailable`: la llamada no se originó.** No hubo marcado, se le devuelve lo reservado y la
  clave vuelve a estar libre: con la misma clave, la llamada se intenta de nuevo.
- **`504 upstream_timeout`: la llamada pudo originarse.** No sabemos si el teléfono sonó, así que el trabajo
  sigue abierto y no se le devuelve nada todavía. Con la misma clave recibe `409 request_in_flight` mientras se
  resuelve. Si nos llega el cierre de la llamada, la clave le devuelve su resultado y se cobra. Si el trabajo
  vence sin él —no salió, o salió y su cierre se perdió—, se le devuelve lo reservado y la clave responde
  `409 job_not_completed`: antes de pedirla con una clave nueva, confirme que la persona no recibió la llamada.
  Con una clave nueva mientras tanto **podría llamar dos veces a la misma persona**.

Fuera de las llamadas, `upstream_timeout` también significa que la operación **puede haberse ejecutado** y
nosotros no tenemos su resultado. En una operación síncrona no se le cobra, y el coste de ese intento lo
absorbemos nosotros.

Un `500` es lo único que conviene escalar: significa que hay un defecto en nuestro código, no en su
integración. Cítenos el `request_id`.

## Cómo escribir el manejo de errores

1. **`2xx`** → siga. Apunte `usage` si va a repercutir el coste.
2. **`5xx` con `Retry-After`** → espere lo que dice y reintente. En `POST /v1/calls`, con la misma clave;
   en el resto, con una nueva.
3. **`429`** → espere lo que dice `Retry-After`. Si es `call_concurrency_limit_reached`, lo que tiene que
   terminar es una llamada, no una petición.
4. **`402`** → pare y mire el `code`: o recarga, o sube su tope, o paga la cuota de su número.
5. **`409 request_in_flight`** → su operación anterior sigue viva. Consulte el trabajo
   ([página 17](./17-jobs.md)) en vez de reintentar en bucle.
6. **`409 agent_outside_schedule`** → agende el reintento con `next_window_at` del cuerpo. Es el único `409`
   que le dice cuándo volver.
7. **El resto de `4xx`** → es su petición o su credencial. Reintentar no cambia nada.

Lo que no conviene: reintentar un `4xx` sin cambiar nada, o reintentar sin respetar `Retry-After`.

---

<!-- fuente: https://gateway.asixto.com/docs/17-jobs.md -->

# 17 · Trabajos, resultados y reintentos

Toda petición a `/v1` crea un **trabajo**, y su identificador viaja en el sobre como `job_id`. En las cinco
operaciones síncronas eso es un detalle de contabilidad; en las dos asíncronas es el hilo por el que se sigue
la operación.

## `GET /v1/jobs/{job_id}`

Es la **red de seguridad de lectura**: quien no monte receptor de webhooks tiene que poder preguntar por su
trabajo y recibir una respuesta que distinga todos los casos.

**cURL**

```bash
curl -sS -X GET "$ASIXTO_BASE_URL/v1/jobs/job_9f2a7c41" \
  -H "Authorization: Bearer $ASIXTO_API_KEY"
```

**Node.js**

```js
const respuesta = await fetch(`${process.env.ASIXTO_BASE_URL}/v1/jobs/job_9f2a7c41`, {
  method: 'GET',
  headers: {
    Authorization: `Bearer ${process.env.ASIXTO_API_KEY}`,
  },
});

const sobre = await respuesta.json();
console.log(sobre.job_id, sobre.usage.credits, sobre.result);
```

**Python**

```python
import os
import requests

url = f"{os.environ['ASIXTO_BASE_URL']}/v1/jobs/job_9f2a7c41"
cabeceras = {
    "Authorization": f"Bearer {os.environ['ASIXTO_API_KEY']}",
}

respuesta = requests.get(url, headers=cabeceras, timeout=120)
respuesta.raise_for_status()

sobre = respuesta.json()
print(sobre["job_id"], sobre["usage"]["credits"], sobre["result"])
```

**PHP**

```php
<?php

$url = getenv('ASIXTO_BASE_URL') . '/v1/jobs/job_9f2a7c41';
$cabeceras = [
    'Authorization: Bearer ' . getenv('ASIXTO_API_KEY'),
];

$ch = curl_init($url);
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => $cabeceras,
]);

$respuesta = curl_exec($ch);
curl_close($ch);

$sobre = json_decode($respuesta, true);
echo $sobre['job_id'], ' ', $sobre['usage']['credits'], PHP_EOL;
```

**Go**

```go
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"os"
)

func main() {
	peticion, _ := http.NewRequest("GET", os.Getenv("ASIXTO_BASE_URL")+"/v1/jobs/job_9f2a7c41", nil)
	peticion.Header.Set("Authorization", "Bearer "+os.Getenv("ASIXTO_API_KEY"))

	respuesta, err := http.DefaultClient.Do(peticion)
	if err != nil {
		panic(err)
	}
	defer respuesta.Body.Close()

	var sobre map[string]any
	json.NewDecoder(respuesta.Body).Decode(&sobre)
	fmt.Println(sobre["job_id"], sobre["usage"], sobre["result"])
}
```

```json
{
  "request_id": "req_9c67575147ae560b557cf5f3",
  "job_id": "cd5e91f3-371e-4ffc-b168-a0beb22f31a5",
  "status": "succeeded",
  "operation": "agent_turn",
  "environment": "live",
  "created_at": "2026-08-30T00:43:59.601Z",
  "started_at": "2026-08-30T00:43:59.640Z",
  "finished_at": "2026-08-30T00:44:01.354Z",
  "result_state": "available",
  "result": { "status": "completed", "stream_id": "cd5e91f3-…", "last_sequence": 1 },
  "usage": { "operation": "agent_turn", "unit": "request", "quantity": 1, "credits": 11 },
  "credits_charged": 11,
  "error": null
}
```

Necesita el permiso `jobs:read`. **Consultar no es ejecutar**, y por eso no reutiliza el permiso de la
operación que creó el trabajo: es la única entrada de la superficie que no gasta créditos ni cupo, así que
puede concedérsela a su panel de soporte sin concederle marcar llamadas ni sintetizar audio.

Los cuatro campos que comparte con el sobre —`request_id`, `job_id`, `usage` y `result`— **significan lo
mismo y se llaman igual**, para que escriba una sola capa de transporte y la use en el acuse, en la
repetición de una clave y en esta consulta.

`request_id` aquí es el de **esta consulta**, no el de la petición que creó el trabajo.

**Lo que no se publica, y es deliberado:** la entrada de su petición —que no se persiste— y la clave de API
que lo creó. El `environment` sí, porque un trabajo no cruza de entorno y una integración que promociona de
pruebas a producción necesita poder verlo.

## `status`: el ciclo de vida

| Valor | Qué significa |
|---|---|
| `queued` | Aceptado, sin empezar |
| `running` | En curso |
| `succeeded` | Terminó bien |
| `failed` | Terminó con un fallo. `error.code` dice cuál |
| `expired` | Se agotó su plazo sin cerrarse |

Los tres últimos son terminales. `expired` no es lo mismo que `failed`: un trabajo cuyo plazo vence libera
su reserva de créditos sin que nadie lo haya cerrado, y confundirlo con un fallo haría imposible auditar esa
liberación.

## `result_state`: cinco casos, no un `result` que a veces es nulo

Un nulo colapsaría situaciones cuya respuesta correcta es distinta.

| Valor | Qué significa | Qué hacer |
|---|---|---|
| `pending` | El trabajo no ha terminado | Vuelva a consultar |
| `available` | El resultado está en `result` | Úselo |
| `expired` | Venció su ventana de retención | Pida la operación con una clave nueva |
| `not_stored` | Esa operación no guarda su resultado por diseño | Pídala con una clave nueva |
| `unavailable` | Terminó sin resultado utilizable | Mire `status` y `error` |

La diferencia entre `expired` y `not_stored` importa: en el primer caso **hubo** un resultado y se purgó; en
el segundo nunca hubo copia. Hoy `not_stored` solo lo produce la síntesis de voz.

## Cuánto vive el resultado

El resultado tiene una **ventana de retención de 24 h**, contada desde que el trabajo se cierra. Dentro de la
ventana, repetir su clave de idempotencia le devuelve el **mismo** resultado sin volver a cobrar; pasada, el
resultado se purga y `result_state` pasa a `expired`.

**`credits_charged` sobrevive a esa purga**, y se publica aparte de `usage` justamente por eso: pasada la
ventana queda el importe y no el desglose. Con solo `usage`, un trabajo consultado al día siguiente parecería
no haber costado nada.

El resto de las ventanas del contrato están en la [página 13](./13-data.md).

## Repetir una clave de idempotencia: caso por caso

Es la misma decisión que toma la consulta de un trabajo, vista desde el otro lado.

| Estado del trabajo anterior | Qué recibe |
|---|---|
| Todavía en curso | `409 request_in_flight` |
| Terminó bien y su resultado se guarda | El **mismo** `job_id`, el mismo `usage` y el resultado. Sin cobrar otra vez |
| Terminó bien y su resultado no se guarda | `409 result_not_replayable`. El cobro no se duplica |
| Terminó bien y su resultado venció la ventana de retención de 24 h | `409 result_not_replayable`, y el mensaje nombra la ventana. Pídala con una clave nueva |
| Terminó con un fallo | El **mismo fallo** que la primera vez |
| Caducó | `409 job_not_completed` |
| La clave se usó para otra operación, o con otras `variables` | `409 idempotency_key_reused` |

Que un fallo se republique tal cual es lo que hace que reintentar con la misma clave sea seguro: no se vuelve
a ejecutar nada ni se vuelve a cobrar. Si lo que quiere es **volver a intentarlo de verdad**, use una clave
nueva.

## Cómo seguir una operación asíncrona

Dos formas, y no son excluyentes:

1. **Webhooks** ([página 11](./11-webhooks.md)): le avisamos cuando el trabajo llega a un estado terminal.
   Es lo que evita consultar en bucle.
2. **Consulta** de `GET /v1/jobs/{job_id}`: siempre disponible, y es lo que usa quien todavía no ha montado
   el receptor o quien está diagnosticando un caso concreto.

Si va a consultar en bucle, espacie las consultas: la consulta no gasta créditos pero sí cuenta para el
techo de tasa de la plataforma ([página 16](./16-api-errors.md)).

Para el turno del agente hay además un tercer camino, que es leer la respuesta **mientras se genera** por el
`stream_url` del acuse. Leerla no cuesta nada, ni la primera vez ni al reanudar
([página 12](./12-pricing.md)).

Ese `stream_url` lo autoriza **el mismo `jobs:read`** que esta consulta, y no el permiso con el que abrió el
turno: una clave que puede conversar pero no leer se queda sin la respuesta que ya pagó. Los detalles del
permiso están en la [página 15](./15-api-reference.md).

## Qué hacer cuando se pierde una respuesta

Es el caso para el que existe todo lo anterior: su proceso murió, su tiempo de espera se agotó, su red
falló.

1. **Repita la petición con la MISMA clave de idempotencia.** Si la operación ocurrió, recibe su resultado
   sin pagarlo dos veces; si no ocurrió, se ejecuta ahora.
2. Si recibe `409 request_in_flight`, la anterior sigue viva. En una operación síncrona, espere lo que
   indica `Retry-After` y repita con la misma clave, o consulte el trabajo si conserva su `job_id`. En una
   llamada, `Retry-After` es el tiempo máximo que le queda (puede durar hasta 900 s, `max_duration_seconds`,
   más un margen de cierre): no reintente antes, porque mientras habla solo consume su límite de peticiones.
   Espere al webhook del cierre o a que pase ese tiempo, y repita entonces
   ([Si lo que perdió es el acuse de una llamada](#si-lo-que-perdio-es-el-acuse-de-una-llamada)).
3. Si recibe `409 result_not_replayable` o `job_not_completed`, la operación no se puede recuperar: pídala
   con una clave nueva.

### Si lo que perdió es el acuse de una llamada

Una llamada aceptada sigue en curso hasta que cuelga, y su trabajo no es terminal mientras tanto. Por eso,
**mientras habla, la misma `Idempotency-Key` responde `409 request_in_flight`**: el `202` del acuse no se
repite. Ese `409` lleva en `Retry-After` los segundos que le quedan como máximo a la llamada, que es una
cota: si cuelga antes, la misma clave ya devuelve su resultado; mientras siga hablando, devuelve el mismo
`409`. Cuando cuelga, la misma clave devuelve el resultado guardado de la llamada
(`status` `completed`, `duration_seconds`, `billed_minutes`) con el `job_id` original y los mismos `warnings`
del acuse; si la llamada falló, devuelve el mismo fallo.

Si perdió el `202`, perdió también el `job_id`, y **mientras la llamada vive la API no se lo devuelve**: el
`409` no lo trae y no hay un listado de trabajos en `/v1`, solo `GET /v1/jobs/{job_id}`. Lo recupera de dos
formas, las dos al colgar:

- **El webhook del cierre** (`job.succeeded`, `job.failed` o `job.expired`) trae `job_id` y, en
  `data.metadata`, el `metadata` que envió al originarla. Envíe en él su identificador y podrá casar el cierre
  aunque no guardara nada nuestro.
- **Repetir la petición con la misma clave** cuando la llamada ya terminó.

En la consola, el listado de trabajos filtra por entorno, estado, operación y fechas, pero no por clave de
idempotencia.

Lo que no conviene: reintentar **sin** clave de idempotencia. Es la única forma de acabar pagando dos veces
por lo mismo.

---

<!-- fuente: https://gateway.asixto.com/docs/18-api-limits.md -->

# 18 · Ritmo y cuotas

El Gateway acota su tráfico en tres planos distintos, y confundirlos lleva a dimensionar mal: el **ritmo** es
cuántas peticiones por segundo acepta su credencial, la **concurrencia** es cuántas atiende a la vez, y los
**créditos** son cuánto puede gastar en el periodo. Un 429 puede venir de cualquiera de los tres, y el `code`
del cuerpo dice de cuál.

## Ritmo por credencial

La cuenta es **por clave de API**, no por dirección IP: repartir la misma clave entre varias máquinas no
multiplica su cupo, y perderla no le regala tráfico a quien la encuentre.

| Política | Cuota | Ventana |
| --- | --- | --- |
| `burst` | 15 peticiones | 1 segundo |
| `sustained` | 600 peticiones | 60 segundos |

Las dos rigen a la vez, así que un pico de 15 en un segundo es legal pero no sostenible sesenta veces. La
ventana es **deslizante**: el cupo se recupera de forma continua, no de golpe al cambiar de minuto.

## Lea el saldo en la respuesta, no el reloj

Las respuestas **correctas** de `/v1`, y el rechazo por el techo de su clave, publican el estado en dos
cabeceras del borrador IETF `httpapi-ratelimit-headers`, que es el vocabulario al que converge la industria:

```http
RateLimit-Policy: "burst";q=15;w=1, "sustained";q=600;w=60
RateLimit: "burst";r=14;t=1, "sustained";r=587;t=60
```

`q` es la cuota y `w` la ventana en segundos; `r` es lo que le queda y `t` los segundos en que libera la
siguiente unidad. Regularse con `r` es mejor que contar peticiones en su lado: el saldo real ya tiene en cuenta
lo que gastaron sus otras máquinas con la misma clave.

Un `r` positivo no es una reserva. Es el saldo en el instante de esa respuesta, y otra máquina suya puede
consumirlo antes de su siguiente petición.

## Cuando le frenan: hay DOS techos, y no traen lo mismo

Sobre `/v1` actúan dos limitadores distintos. Los dos responden **429** y los dos traen `Retry-After` en
segundos, que es lo único que necesita para reintentar bien. Se diferencian en el `code` y en si hay saldo
que leer:

| `code` | Qué techo es | Cómo se reparte | ¿Trae `RateLimit`? |
| --- | --- | --- | --- |
| `key_rate_limited` | El de **su clave** | Por credencial | **Sí.** `r=0` en la política que le frenó |
| `rate_limited` | El de **la plataforma** | Por dirección IP de origen | No: el cupo no es suyo, es compartido |

Si llama desde **una sola máquina**, lo normal es encontrar primero el techo de la plataforma. El de su clave
aparece cuando reparte la misma credencial entre varias máquinas, que es lo que ese límite existe para acotar.

**Programe contra `Retry-After`, no contra el saldo.** El saldo sirve para no llegar al rechazo; la cabecera
de espera sirve para salir de él, y está en los dos casos. Los otros tres 429 —`concurrency_limit_reached`,
`call_concurrency_limit_reached` y `period_quota_exhausted`— no se arreglan bajando el ritmo, así que mire el `code` antes de reintentar
([página 16](./16-api-errors.md)).

## Concurrencia y cupo del periodo: por cuenta **y por entorno**

Los otros dos planos no se cuentan por clave sino por cuenta, y hay un juego de techos **para cada entorno**:
las peticiones y llamadas en vuelo de `ak_test_` no ocupan las ranuras de `ak_live_`. El dinero, en cambio,
**no** se separa: los dos entornos cobran y gastan del mismo saldo ([página 14](./14-api-quickstart.md)), y
ninguno tiene un techo de créditos propio. Lo que acota el gasto de un bucle de pruebas es el saldo y el tope de
gasto de la cuenta ([página 16](./16-api-errors.md)), no un cupo del entorno.

| Techo | Con el que se abre una cuenta en la beta, en cada entorno | Cuando se llena |
| --- | --- | --- |
| Peticiones simultáneas | **10** | `429 concurrency_limit_reached`, con `Retry-After` |
| Llamadas de voz simultáneas | **5** | `429 call_concurrency_limit_reached`, con `Retry-After` |
| Créditos del periodo | **sin techo**: manda el saldo de la cuenta | — |

Son dos recursos distintos y no un alias: una petición síncrona ocupa su ranura segundos y no cuesta nada
mientras espera, y una llamada ocupa un canal del proveedor y un flujo de audio durante minutos, con coste por
minuto. Con un solo techo, cinco llamadas largas le dejarían sin poder transcribir un audio.

Si a su cuenta le fijamos un techo de créditos por periodo, hay dos rechazos y no son el mismo:
`429 period_quota_exhausted`, con `Retry-After`, cuando lo que queda del periodo no cubre la operación, y
`409 request_exceeds_period_quota`, **sin** `Retry-After`, cuando la operación no cabe ni con el periodo vacío.
Esperar arregla el primero; el segundo solo se arregla subiendo el techo.

El periodo es el **mes natural en UTC**, el mismo con el que se cuenta su saldo, para que un techo no se
reinicie cuando el saldo no.

Los techos se cambian a petición. Si el suyo no coincide con esta tabla, manda el de su cuenta: pregúntenos antes
de dimensionar contra los números de arriba.

> **Una cuenta sin cupo en un entorno no opera en ese entorno**, y no responde `429` sino
> `403 gateway_not_enabled`: la ausencia de techo no significa «sin límite». Es lo que hace que una cuenta
> pueda estar admitida en pruebas y todavía no en producción, que es el estado normal mientras integra.

## Cadencia de sondeo de un trabajo

`GET /v1/jobs/{job_id}` es gratis y no consume ranura de concurrencia, pero sí consume ritmo. Si sigue un
trabajo por sondeo en vez de por webhook, **espere 2 segundos entre consultas** y aumente la espera al doble
tras cada respuesta que siga `queued` o `running`, hasta un techo de 30 segundos. Un bucle sin espera agota la
ventana `burst` de toda su clave en un segundo y frena las peticiones que sí trabajan.

Con receptor de webhooks no hace falta sondear: el `job.succeeded` llega solo
([página 11](./11-webhooks.md)).

## Qué NO cuenta contra su ritmo

El transporte en streaming del asistente mantiene la conexión abierta y no repite peticiones, así que no gasta
ventana mientras emite. Lo que sí la gasta es **abrir** el turno.

---

<!-- fuente: https://gateway.asixto.com/docs/19-api-stream.md -->

# 19 · El stream del turno

El turno del agente se lee **mientras se genera**, por `GET /v1/agent/streams/{job_id}`, como
Server-Sent Events. Es el tercer camino para seguir una operación asíncrona, además del webhook y de la
consulta del trabajo ([página 17](./17-jobs.md)), y el único que entrega la respuesta antes de que termine.

## Cómo se abre

La ruta **no se compone a mano**: el acuse de `POST /v1/agent/turns` la trae en `stream_url`
([página 15](./15-api-reference.md)). El `stream_id` de ese acuse es el mismo `job_id` del sobre, así que la
respuesta, el trabajo y su cobro se citan con un solo identificador.

```
GET /v1/agent/streams/{job_id}
Authorization: Bearer ak_live_…
Accept: text/event-stream
```

Lo autoriza **`jobs:read`**, el mismo permiso que la consulta del trabajo, y no el `agent:converse` con el que
abrió el turno: quien paga la respuesta puede leerla. Sin él, un turno se paga y no se puede leer.

La respuesta es `200` con `Content-Type: text/event-stream`, sin caché y sin transformación por el camino. La
primera línea que recibe es un comentario, `: connected`.

Un trabajo que no existe y uno de otra cuenta responden **lo mismo**, `404 resource_not_found`. Distinguirlos
convertiría esta ruta en un oráculo de identificadores ajenos. Una clave `ak_test_` tampoco lee un stream de
producción: para ella ese trabajo no existe.

## Eventos

Tres, y ninguno más. El nombre de la línea `event:` es **el mismo valor** que el campo `event_type` del
`data`, así que puede programar contra cualquiera de los dos sin traducir nada.

| Evento | Qué es | ¿Cierra? |
|---|---|---|
| `response_chunk` | Un tramo de la respuesta del agente. Concaténelos en orden | No |
| `response_end` | La generación terminó bien | **Sí** |
| `error` | La generación se interrumpió. `content` trae el motivo | **Sí** |

Hoy el turno emite **un solo `response_chunk`** con la respuesta entera, y después su `response_end`. No
programe contra esa cantidad: concatenar tramos es correcto con uno y con cincuenta.

`response_chunk` describe una generación viva; los otros dos son **terminales**.

## Forma de `data`

Cada trama lleva un objeto JSON en `data`, con los nombres en `snake_case` igual que el resto de `/v1`.

| Campo | Tipo | Qué es |
|---|---|---|
| `job_id` | texto | El trabajo que genera la respuesta. Es el mismo de la ruta y del sobre |
| `conversation_id` | texto | La conversación a la que pertenece el turno. Puede venir vacío |
| `event_type` | texto | Uno de los tres eventos. Coincide con la línea `event:` |
| `content` | texto | El tramo de respuesta; en el `error`, el motivo |
| `sequence` | entero | Posición del tramo. **Puede faltar** |
| `timestamp` | texto | Momento del tramo, en ISO 8601 |

```
: connected

id: 0
event: response_chunk
data: {"job_id":"cd5e91f3-371e-4ffc-b168-a0beb22f31a5","conversation_id":"a3f1…","event_type":"response_chunk","content":"Su pedido salió ayer.","sequence":0,"timestamp":"2026-08-30T00:44:01.120Z"}

id: 1
event: response_end
data: {"job_id":"cd5e91f3-371e-4ffc-b168-a0beb22f31a5","conversation_id":"a3f1…","event_type":"response_end","content":"","sequence":1,"timestamp":"2026-08-30T00:44:01.354Z"}
```

🔴 **`sequence` puede faltar, y la clave se omite en vez de viajar en `null`.** Cuando falta, la trama tampoco
trae línea `id:`: no hay posición que reanudar, y fabricarle un número haría que dos lectores del mismo stream
numeraran distinto. Le llega así el `error` que emite el propio transporte, y puede llegarle cualquier evento
sin posición durante un despliegue nuestro. Su cliente concatena igual; lo que no puede hacer es usar esa
trama como punto de reanudación.

`job_id` y `request_id` **no son lo mismo** y los dos viajan en el sobre: `request_id` correlaciona una
petición concreta, `job_id` identifica el trabajo. En el stream va el `job_id`.

## Tras el terminal, cerramos

Al emitir `response_end` o `error`, el servidor **cierra la conexión**. No hay nada más que llegue por ella.

Eso importa porque `EventSource` reconecta solo: si no cierra el suyo al recibir el terminal, reabrirá el
stream de un turno ya terminado y recibirá otra vez lo que quede en ventana, o un `409` cuando ya no quede
nada. No le cuesta créditos, pero es tráfico que no le aporta nada.

Una interrupción **después** del `200` no puede llegarle como código HTTP —las cabeceras ya están escritas—,
así que viaja dentro del stream: recibe un evento `error` con el motivo en `content`, y después el cierre. Son
dos motivos, y distinguen dónde falló:

| `content` | Qué pasó |
|---|---|
| `turn_failed` | La generación no llegó a producir respuesta |
| `stream_interrupted` | La generación siguió; lo que se cortó fue esta entrega |

Con `stream_interrupted` merece la pena reabrir el stream con `Last-Event-ID`; con `turn_failed` no hay nada
más que llegue, y el estado del trabajo lo cuenta `GET /v1/jobs/{job_id}`.

## Comentarios: `: connected` y `: ping`

Toda línea que empieza por `:` es un comentario del protocolo y su cliente **debe ignorarla**. Hay dos:

- `: connected`, en cuanto la conexión se establece.
- `: ping`, cada 20 segundos mientras el stream está callado, para que ningún intermediario cierre una
  conexión que parece inactiva.

No son eventos: no traen `event:`, no traen `data:` y no cuentan para la secuencia. Las bibliotecas de SSE ya
las descartan; si lee la respuesta a mano, descártelas usted.

## Reanudar con `Last-Event-ID`

Cada trama con posición sale con `id: <sequence>`, un entero que empieza en 0. Para empalmar una conexión
cortada, reabra la misma ruta declarando el último que vio:

```
GET /v1/agent/streams/{job_id}
Last-Event-ID: 3
```

Recibe lo publicado **después** de esa posición y sigue en directo. `EventSource` lo manda solo. Un valor que
no sea un entero no negativo se ignora, y entonces la reanudación empieza desde el principio de lo que quede
guardado — así que una conexión nueva sin cabecera también recibe lo que se publicó antes de conectar.

**La ventana de reanudación son 2 minutos** y cubre hasta 200 tramos. No es una copia del resultado: es el
tramo que permite empalmar una reconexión. Cuando lo que falta ya venció, abrir el stream responde
**`409 result_not_replayable`** —lo reciba con `Last-Event-ID` o sin él— porque el hueco no se disimula: el
trabajo existe y su cobro también, y lo que no existe es una copia de su salida. Entregarle solo el tramo que
queda dejaría una respuesta corrupta con aspecto de completa.

Cuando eso pasa, la copia durable sigue donde la promete el contrato: el resultado del trabajo, 24 h, en
`GET /v1/jobs/{job_id}` ([página 17](./17-jobs.md)). Si necesita la respuesta generada otra vez, pida el turno
con una clave de idempotencia **nueva**.

## Streams simultáneos

**50 por cuenta y entorno.** Cuando el techo está lleno, abrir otro responde
`429 concurrency_limit_reached` con `Retry-After` en segundos.

Es un techo **propio**, distinto del de peticiones simultáneas ([página 18](./18-api-limits.md)) aunque
comparta el código de error: leer una respuesta que ya pagó no puede competir con originar trabajo nuevo. La
ranura la ocupa el **trabajo** que se lee, no la conexión, así que reconectar sobre el mismo `job_id` no
consume otra.

Mantener la conexión abierta no gasta ritmo de su clave. Lo que lo gasta es **abrir** el turno.

## Leer no cuesta nada

El turno se cobra **una vez**, por respuesta generada ([página 12](./12-pricing.md)). Leer el stream no
cuesta nada, ni la primera vez ni al reanudar: el cobro sigue a la generación, no a la entrega. Si su cliente
se desconecta a mitad, el turno cuesta lo mismo, y cortar el stream no cancela la generación.

## Los rechazos que sí son código HTTP

Todo lo que se decide **antes** del `200` llega como error normal de la superficie, con la forma de la
[página 16](./16-api-errors.md).

| Código | HTTP | Qué pasó |
|---|---|---|
| `authentication_required` | 401 | Falta la credencial o no identifica una cuenta |
| `insufficient_scope` | 403 | Su clave no tiene `jobs:read` |
| `resource_not_found` | 404 | El trabajo no existe, es de otra cuenta o es de otro entorno |
| `result_not_replayable` | 409 | La ventana de reanudación de ese stream venció |
| `concurrency_limit_reached` | 429 | Su techo de streams simultáneos está lleno. Trae `Retry-After` |

## Qué leer después

| Para | Vaya a |
|---|---|
| Abrir el turno y qué trae su acuse | [página 15](./15-api-reference.md) |
| El sobre, la clave de idempotencia y la autenticación | [página 14](./14-api-quickstart.md) |
| Consultar el trabajo cuando pierde el stream | [página 17](./17-jobs.md) |
