# API seguras para agentes de IA: escrituras rastreables

Un agente de IA no vuelve peligrosa una API por sí solo. Una API se vuelve peligrosa cuando ofrece verbos demasiado amplios, resultados ambiguos y errores que obligan al cliente a adivinar. Los operadores humanos compensan con contexto, cautela y un mensaje rápido en el chat. Un agente compensa con reintentos y otra llamada a una herramienta. Esa diferencia puede convertir un tiempo de espera inofensivo en dos reembolsos, dos despliegues o un registro borrado por accidente.

Las API seguras para agentes de IA limitan la acción permitida, hacen que las escrituras repetidas sean inocuas y dejan un rastro que una persona pueda seguir después. Esto es diseño de API, no trabajo de prompts. Un prompt puede pedirle a un agente que tenga cuidado, pero el endpoint debe rechazar cualquier acción que quede fuera de su contrato.

He visto equipos colocar pantallas de aprobación delante de un endpoint administrativo poco definido y llamar a eso un control. No basta. Si la llamada aprobada significa «cambia cualquier cosa de esta cuenta», la aprobación obliga a una persona a revisar un conjunto de consecuencias ocultas bajo presión. Primero incorpora la precisión en la API. Así la aprobación tendrá algo inteligible que aprobar.

## Los verbos amplios obligan a los agentes a adivinar

Un agente debería llamar a una operación cuyo nombre, entradas y efectos secundarios quepan en una sola frase. Los endpoints amplios le obligan a deducir reglas de negocio a partir de campos poco definidos, ejemplos antiguos o un error que solo dice «solicitud incorrecta». Ahí empieza la improvisación insegura.

Considera un endpoint como `POST /admin/execute` con un cuerpo que contiene `action` y JSON arbitrario. Un cliente escrito por una persona quizá use hoy solo cinco acciones, pero el endpoint presenta todas las acciones actuales y futuras a cualquier cliente que obtenga acceso. El servidor no puede comunicar un límite de permisos útil, y un aprobador no puede saber qué hará el agente sin leer el cuerpo como si fuera código fuente.

Sustitúyelo por operaciones que nombren una transición de estado:

- `POST /projects/{project_id}/deployments` crea un despliegue a partir de una revisión concreta.
- `POST /invoices/{invoice_id}/refunds` crea un reembolso con un importe y un motivo explícitos.
- `POST /users/{user_id}/access-revocations` elimina el acceso de un usuario concreto.
- `POST /exports` inicia una exportación definida con una categoría de datos declarada.

Estas operaciones aún pueden implicar riesgos. La ventaja es que cada una ofrece al servidor un lugar donde aplicar reglas: transiciones válidas, límites de importe, propiedad del objetivo, aprobaciones obligatorias y un campo de motivo donde corresponde.

No confundas una interfaz CRUD genérica con una interfaz útil para agentes. `PATCH /customers/{id}` invita al cliente a cambiar cualquier campo editable. Si cambiar `billing_email` es rutinario, pero cambiar `tax_status` inicia un proceso de cumplimiento, esos cambios no deberían quedar detrás del mismo parche informal. Crea una operación específica para la transición importante y haz que su modelo de entrada refleje la decisión.

Una operación limitada también mejora la recuperación. Cuando un agente dice «la solicitud de despliegue agotó el tiempo de espera», un operador puede buscar una sola creación de despliegue. Cuando dice «el comando administrativo agotó el tiempo de espera», primero debe descubrir qué comando preparó.

### Incluye las precondiciones en la solicitud

Las escrituras deben indicar la condición bajo la que tienen sentido. Una solicitud para aprobar un gasto puede incluir el estado de revisión esperado. Una solicitud para actualizar un documento puede incluir la versión que leyó. Si el estado cambió, el servidor debe rechazar la escritura en lugar de aplicarla silenciosamente a una realidad distinta.

HTTP ya ofrece mecanismos útiles. RFC 9110 define solicitudes condicionales mediante encabezados como `If-Match`; un servidor puede rechazar una etiqueta de entidad obsoleta con `412 Precondition Failed`. También puedes exponer un campo `expected_version` si encaja mejor con tu API. Importa menos la elección que la disciplina: el cliente debe indicar la versión o el estado que pretende modificar.

No aceptes un campo del cliente como `force: true` para escapar de cualquier conflicto. Ese campo suele convertirse en una forma de que los agentes arrasen con el control de seguridad que acabas de añadir. Reserva la excepción para otra operación, un nivel de autorización distinto y un registro de auditoría visible.

## Una escritura necesita una identidad independiente del intento HTTP

Toda escritura visible desde el exterior debería tener un identificador de idempotencia proporcionado por el cliente. El servidor lo usa para reconocer que varios intentos de entrega expresan la misma acción prevista.

Un ID de solicitud y un identificador de idempotencia resuelven fallos distintos. Una pasarela o un servidor suele crear un ID de solicitud para cada intento HTTP. Si la red se interrumpe después de que el servidor confirme la escritura, pero antes de que la respuesta llegue al cliente, el reintento recibe un ID de solicitud nuevo. El identificador de idempotencia debe conservarse porque la escritura prevista no ha cambiado.

La secuencia habitual es esta:

1. El agente envía una solicitud para crear un pago.
2. Tu servidor guarda el pago y llama a un proveedor externo.
3. La conexión falla antes de que el agente reciba la respuesta correcta.
4. El agente ve un resultado desconocido y reintenta.
5. Tu servidor crea otro pago porque ve una solicitud HTTP nueva.

La política de reintentos no causó el defecto. Lo causó la API al tratar la entrega como si fuera la intención.

Usa un encabezado o un campo de solicitud que el cliente cree antes del primer intento y conserve hasta recibir una respuesta definitiva. Los nombres de encabezados HTTP suelen usar `Idempotency-Key`, aunque el identificador no tiene por qué ser secreto. Un UUID aleatorio funciona bien. No lo derives únicamente de una marca de tiempo ni uses un identificador que pueda colisionar entre escrituras no relacionadas.

### Guarda la huella de la solicitud y el resultado

El servidor debe vincular un identificador de idempotencia a algo más que una bandera de estado. Guarda la identidad del cliente, la ruta objetivo, una huella canónica del cuerpo relevante de la solicitud y el resultado completo necesario para repetir la respuesta. Cuando el mismo cliente reintenta con el mismo identificador y la misma huella, devuelve la respuesta original. Si el cuerpo difiere, rechaza la solicitud con un conflicto.

El borrador del IETF «The Idempotency-Key HTTP Header Field» describe este encabezado como una forma de que los clientes hagan tolerantes a fallos los métodos HTTP no idempotentes. Su advertencia sobre la unicidad es importante: el cliente no debe reutilizar un valor para otra solicitud. Yo iría un paso más allá en la implementación. Haz cumplir esa advertencia en el servidor, porque los agentes reintentan, se reinician y a veces reutilizan estados que un cliente humano habría descartado.

Un contrato compacto puede verse así:

```http
POST /v1/projects/prj_48/deployments
Idempotency-Key: 8c8d77c1-4ef9-4fae-b0ba-5480f686ce4c
Content-Type: application/json

{
  "revision": "a1b2c3d4",
  "environment": "staging",
  "expected_project_version": 17
}
```

En la primera llamada aceptada, devuelve un recurso y ambos identificadores:

```json
{
  "request_id": "req_01J8X7QK3JZ6",
  "deployment": {
    "id": "dep_01J8X7R5G2",
    "state": "queued",
    "revision": "a1b2c3d4",
    "environment": "staging"
  }
}
```

Si el agente repite la solicitud idéntica después de un tiempo de espera, devuelve el mismo `dep_01J8X7R5G2`, no un segundo despliegue. Si cambia `environment` a `production` manteniendo el identificador, devuelve un conflicto que indique claramente cómo corregirlo:

```json
{
  "error": {
    "code": "idempotency_payload_mismatch",
    "message": "This idempotency identifier belongs to a deployment request with different parameters.",
    "request_id": "req_01J8X84S9P2V"
  }
}
```

Conserva los registros de idempotencia al menos durante el tiempo en que puedan producirse reintentos realistas del cliente y recuperaciones de trabajos. Un periodo de retención demasiado corto crea un duplicado tardío que en producción parece intermitente. Si la presión de almacenamiento obliga a eliminar registros, documenta claramente el intervalo y haz que los consumidores elijan una estrategia de reintento que lo respete.

## Reintentar solo tiene sentido cuando el resultado se conoce lo suficiente

Un agente debería reintentar fallos de transporte y determinadas respuestas transitorias, pero nunca debería inventar una acción nueva para escapar de la incertidumbre. Las clases de respuesta deben permitir tomar esa decisión.

RFC 9110 define `429 Too Many Requests` y permite usar `Retry-After`; respeta ese mecanismo si lo envías. El cliente puede esperar el tiempo indicado, conservar el identificador de idempotencia y enviar la misma solicitud. Para un fallo temporal del servidor, devuelve una respuesta 5xx con un ID de solicitud e indica si el servidor aceptó la operación. No uses un 500 ambiguo para un fallo de validación o una negativa de autorización. Enseña a los clientes una estrategia de reintento equivocada.

En las escrituras asíncronas, la aceptación y la finalización son hechos distintos. Una respuesta `202 Accepted` debería devolver un recurso de operación que identifique el trabajo y su estado. Después de un tiempo de espera, el agente puede consultar ese recurso en lugar de volver a enviar un efecto secundario.

```json
{
  "request_id": "req_01J8X9FW7GH2",
  "operation": {
    "id": "op_01J8X9FTVX",
    "state": "running",
    "status_url": "/v1/operations/op_01J8X9FTVX"
  }
}
```

El recurso de estado necesita más que `running` y `failed`. Incluye un estado terminal, una referencia al resultado cuando la operación termina correctamente y un código de fallo público cuando el trabajador no puede completar el trabajo. Un despliegue que no supera las comprobaciones de salud, por ejemplo, no debería parecer un fallo de transporte de la API. El agente debe informar o reparar el fallo del despliegue; solo debería reintentar un fallo de conexión cuando el servidor nunca aceptó la solicitud.

Evita los reintentos automáticos para acciones que envíen correos, cobren dinero, roten credenciales o llamen a un sistema externo, salvo que tu servidor controle la deduplicación hasta el efecto final. La idempotencia en tu base de datos no evita dos correos si un trabajador falla después de que el proveedor de correo acepte el mensaje y antes de que el trabajador registre la finalización. Usa un registro outbox con una referencia estable de deduplicación del proveedor cuando este lo permita. Si el sistema externo no puede deduplicar, haz que la operación sea observable y exige una decisión humana después de un resultado desconocido.

## Los errores deben indicar cómo corregir la solicitud

Los mensajes de error útiles describen el contrato incumplido, no la vergüenza del servidor. Un agente puede trabajar con un error preciso. No puede trabajar de forma segura con una página de error HTML, una traza de pila o «entrada no válida» después de una solicitud con diez campos.

Devuelve un sobre JSON coherente para cada fallo esperado. Incluye un `code` estable para los programas, un `message` conciso para los registros y las personas, un ID de solicitud y detalles por campo cuando sea seguro exponerlos. RFC 9457, «Problem Details for HTTP APIs», ofrece una estructura estándar mediante campos como `type`, `title`, `status`, `detail` e `instance`. No necesitas adoptar todos los campos para entender su lección principal: los errores forman parte del contrato de la API, no son texto incidental.

Esta respuesta indica al agente exactamente qué debe cambiar:

```json
{
  "error": {
    "code": "invalid_state_transition",
    "message": "A refund can be created only for a paid invoice.",
    "request_id": "req_01J8XAS2D8M4",
    "details": {
      "invoice_id": "inv_204",
      "current_state": "draft",
      "allowed_states": ["paid", "partially_paid"]
    }
  }
}
```

Esta respuesta obliga a adivinar:

```json
{
  "error": "Request failed"
}
```

La segunda respuesta devuelve al agente a la documentación, el código fuente o las llamadas exploratorias. Las llamadas exploratorias contra una API de escritura son la forma en que un defecto pequeño se convierte en un incidente ruidoso.

No incluyas secretos en los errores. No repitas encabezados de autorización, tokens de acceso, URL firmadas, consultas SQL sin procesar ni respuestas de servicios externos que puedan contener datos de otro cliente. Un patrón problemático consiste en capturar cualquier excepción y devolver su mensaje al cliente. Eso facilita la depuración durante un día y crea un canal de filtración durante años.

Separa la entrada no válida de la autoridad insuficiente. `422 Unprocessable Content` puede describir un cuerpo bien formado que incumple una regla de negocio. `403 Forbidden` debería indicar que la operación solicitada requiere un permiso o una aprobación, sin revelar recursos que el cliente no puede inspeccionar. `404 Not Found` puede tener sentido cuando ocultas deliberadamente la existencia del recurso. Elige la semántica, documéntala y aplícala de forma coherente.

Un buen error también indica cuándo no tiene sentido reintentar. `invalid_state_transition`, `idempotency_payload_mismatch` y `approval_required` deberían detener los reintentos ciegos. `rate_limited`, con un retraso de reintento, y `upstream_temporarily_unavailable` pueden invitar a un reintento controlado. Esa distinción evita más daños que un prompt ingenioso para el agente.

## Los identificadores de solicitud convierten una acción discutida en una investigación

Asigna un ID de solicitud a cada solicitud entrante, devuélvelo en el cuerpo o encabezado de la respuesta y pásalo a cada llamada interna, mensaje de cola, trabajo de un trabajador y llamada a un proveedor externo. Cuando un agente dice que no recibió respuesta, necesitas contestar dos preguntas distintas: ¿aceptó tu API la acción y qué hizo después cada componente?

Genera el ID de solicitud en el límite de confianza si el cliente no proporciona uno. Puedes aceptar un ID de correlación del cliente para su propio registro, pero no permitas que un cliente no confiable sobrescriba el identificador emitido por el servidor. Conserva ambos cuando sea útil. El identificador del servidor ancla tus registros; el del cliente conecta una secuencia de decisiones del agente.

Registra eventos estructurados en lugar de crear líneas de texto que los operadores tengan que analizar después con expresiones regulares. Como mínimo, registra el ID de solicitud, la identidad autenticada, el nombre de la operación, el recurso objetivo, el identificador de idempotencia si existe, la decisión de autorización, el estado del resultado y las referencias a los recursos creados. Redacta los campos de la solicitud según un esquema, no mediante un filtro de texto de última hora. Un campo llamado `token` es fácil de ocultar. Una credencial incrustada en texto arbitrario no lo es.

La traza debe conservar el orden sin fingir que demuestra más de lo que realmente demuestra. Un ID de solicitud puede mostrar que tu API aceptó un trabajo y que un trabajador envió una llamada al proveedor. No puede demostrar que una persona pretendía la acción, salvo que tu sistema registre esa decisión por separado. Mantén clara la diferencia:

- Un registro de correlación conecta eventos que pertenecen a una solicitud.
- Un registro de auditoría indica quién o qué autorizó una acción y qué hizo el sistema.
- Un registro de idempotencia evita una escritura lógica duplicada.

Los equipos suelen combinar todo esto en una sola fila de base de datos. Entonces la fila debe servir para los reintentos, la depuración, las revisiones de cumplimiento y el historial visible para el usuario, y no cumple bien ninguna función. Puedes guardar referencias relacionadas juntas, pero conserva los significados separados en el modelo de datos.

Para acciones de mayor riesgo, registra la solicitud normalizada, el contexto de autorización, el resultado de la política o aprobación y un resumen del resultado en un flujo de auditoría de solo adición. Protege ese flujo frente a la cuenta de aplicación normal. De lo contrario, un servicio comprometido puede reescribir el historial que lo dejaría al descubierto.

Sallyport adopta un enfoque útil para las acciones de agentes: registra las sesiones de los agentes y las llamadas individuales en un único registro de auditoría cifrado, ciego a las escrituras y encadenado mediante hashes, y `sp audit verify` comprueba la cadena sin conexión y sin una clave de la bóveda. Tu API sigue necesitando sus propios registros, porque una pasarela puede mostrar que envió una llamada, mientras que solo tu servicio puede mostrar la transición de estado que confirmó.

## El alcance de la autenticación no arregla una operación insegura

Las credenciales de corta duración y los alcances limitados reducen el radio de impacto, pero no hacen seguro un endpoint amplio. Un token limitado a un proyecto todavía puede destruir todos sus despliegues, exportar todos los datos permitidos o activar cualquier acción administrativa disponible en ese proyecto.

Vincula la autorización a la operación y al objetivo. Un cliente autorizado para crear un despliegue no debería poder promoverlo automáticamente a producción. Un cliente autorizado para revocar el acceso de un usuario no debería recibir permiso para cambiar su perfil de facturación solo porque ambas cosas están bajo `/users/{id}`.

Mantén las credenciales fuera del agente siempre que puedas. Un agente que recibe un token Bearer puede copiarlo a una transcripción, un archivo de depuración, el historial del shell o una llamada a un servicio externo. En su lugar, coloca el uso de credenciales detrás de una pasarela de acciones local o de un intermediario del lado del servidor que seleccione la credencial para una operación aprobada. El agente envía la intención y los parámetros; el componente confiable inyecta el secreto solo al realizar la llamada externa.

Este diseño no elimina la necesidad de validar los parámetros. Si un agente puede enviar `url: https://anything.example`, un ayudante HTTP que inyecta credenciales puede convertirse en una herramienta para exfiltrar secretos. Vincula las credenciales a hosts y métodos externos concretos. Valida los hosts después de las redirecciones y también antes de ellas. En SSH, vincula una credencial a hosts conocidos y, cuando sea posible, a una interfaz de comandos restringida, en lugar de ofrecer acceso arbitrario a un shell remoto.

La aprobación humana tiene su lugar, pero debería cubrir una acción pequeña con un objetivo y una consecuencia visibles. La aprobación por sesión responde a «¿puede este proceso de agente actuar?». La aprobación por llamada responde a «¿puede realizar ahora esta acción sensible concreta?». Ninguna rescata un endpoint cuyo cuerpo pueda significar cualquier cosa.

## La concurrencia necesita un perdedor explícito

La idempotencia detiene la entrega duplicada de una intención. No resuelve dos intenciones distintas que compiten entre sí. Si dos agentes leen una factura en estado `paid` y ambos envían un reembolso completo con identificadores de idempotencia diferentes, tu servidor debe decidir qué solicitud gana.

Usa una transición de estado transaccional cuando tu sistema de almacenamiento lo permita. La actualización debe incluir el estado que espera y el servidor debe informar del conflicto si otro escritor lo cambió antes. Un campo de versión, una etiqueta de entidad o una actualización condicional dan a la API una forma de rechazar una intención obsoleta en lugar de aplicarla después de que cambien los hechos.

Por ejemplo, modela un reembolso como una operación sobre el saldo reembolsable restante, no como un comando ciego que confía en un importe enviado por el cliente. En una transacción, comprueba el importe pagado actual, resta los reembolsos anteriores, valida el importe solicitado, reserva el nuevo reembolso y crea su registro. Un trabajador asíncrono separado puede llamar al proveedor de pagos después de que exista esa reserva. Si el trabajador reintenta, continúa con el mismo registro de reembolso en lugar de crear otro.

No indiques a los agentes que «comprueben primero y actúen después» como único control de concurrencia. Un `GET` previo ayuda al agente a preparar una solicitud útil, pero otro cliente puede cambiar el estado entre la lectura y la escritura. El endpoint de escritura es responsable de la corrección porque ve el estado real en el momento de confirmar.

Diseña la cancelación con el mismo cuidado. `DELETE /operations/{id}` no debería prometer que nunca ocurrió una acción externa. Debe devolver el estado real de la cancelación: cancelación solicitada, cancelada antes del envío, completada antes de la cancelación o imposible de cancelar después del envío. Tanto los agentes como las personas necesitan un lenguaje que refleje el límite entre tu sistema y el proveedor externo.

## Prueba los resultados desconocidos antes de que los agentes los encuentren en producción

Una suite de pruebas que solo comprueba una respuesta 200 enseña a todos a ignorar la parte más difícil de las API de acciones. Incluye los fallos en las pruebas de contrato y ejecútalas contra un límite de servicio real, no solo contra un controlador simulado.

Para cada operación de escritura, prueba una secuencia en la que el servidor confirma el efecto y el cliente pierde la respuesta. Envía de nuevo el mismo identificador de idempotencia y comprueba que el servidor devuelve el recurso original. Después envía ese identificador con un cuerpo modificado y comprueba que el servidor devuelve un conflicto sin crear otro recurso.

Prueba solicitudes simultáneas con identificadores de idempotencia diferentes contra la misma transición de estado. Comprueba que una tiene éxito y que la otra recibe un error específico de estado obsoleto o de regla de negocio. Si ambas tienen éxito en una base de datos de prueba porque cada prueba se ejecuta por separado, no has probado la propiedad importante.

Prueba el contrato de errores como datos. Comprueba los códigos de estado, los códigos de error estables, los nombres de los campos y la presencia de un ID de solicitud. No guardes únicamente una instantánea del mensaje en español. Con el tiempo mejorarás la redacción; los clientes deben decidir según `code`, no según el texto.

Por último, haz un simulacro para operadores. Elige una acción completada, una rechazada, un tiempo de espera con una confirmación correcta en el servidor y un fallo asíncrono. Entrega a un ingeniero solo los ID de solicitud y pídele que reconstruya lo ocurrido. Si necesita buscar en registros no relacionados, inspeccionar una transcripción del agente y adivinar qué reintento creó cada registro, corrige la instrumentación antes de permitir escrituras sin supervisión.

La primera acción que suele necesitar reparación es el endpoint de escritura más amplio. Divídelo en transiciones con nombre, exige un identificador de idempotencia y haz que la respuesta identifique el recurso resultante. Cuando exista ese contrato, los agentes podrán actuar rápidamente sin tratar cada interrupción de red como permiso para probar algo distinto.
