Skip to main content

Dale a tu agente de IA un sistema nervioso

15 conceptos, ~80% del uso real: sentidos (disparadores), reflejos (ejecución duradera) y equilibrio (control de flujo).

Has construido un agente que funciona. También funciona solo mientras tú lo observas. Abres Claude Code u OpenCode, escribes y responde. Y en cuanto te alejas, se detiene. Esa brecha, entre un agente que tú operas y un Worker que opera por sí solo, es el tema completo de este curso.

Lo sorprendente es qué cierra la brecha, y no es un agente más inteligente. Tu agente ya tiene lo que necesita para hacer el trabajo: un LLM para pensar, herramientas y servidores MCP para actuar, habilidades para los flujos de trabajo que conoce. Lo que no tiene es un sistema nervioso. Piensa en tu propio cuerpo: tu cerebro piensa y tus músculos actúan, pero un segundo sistema funciona por debajo sin ti, tu latido y tus reflejos, las señales que te mantienen vivo mientras duermes. Deja de prestar atención y tu corazón sigue latiendo; un agente no tiene una versión de eso, así que en cuanto dejas de conducirlo, se detiene. Un sistema nervioso es el tejido conectivo que cierra el ciclo por sí solo, sin que un humano dirija cada turno: percibe el mundo y despierta al agente cuando algo sucede, reacciona por reflejo cuando un paso falla (y conserva su lugar durante horas mientras espera a una persona o a una API lenta) y mantiene al agente en equilibrio cuando llegan quinientas solicitudes a la vez. Esa es la línea entre un agente que tú operas y un FTE que opera por sí solo. Tú le das a tu agente este sistema nervioso; no reescribes el agente. Esa es la única idea sobre la que se construye este curso.

La herramienta que le da a tu agente un sistema nervioso tiene un nombre técnico, un motor de ejecución duradera, y usamos uno llamado Inngest. Los patrones se transfieren a Temporal, Restate y Dapr Agents. Esto no es solo una imagen didáctica. Day AI, un CRM creado para empresas nativas de IA, llama a Inngest "el sistema nervioso" de su producto y se apoya en cada parte que enseña este curso. El nivel gratuito Hobby de Inngest es el lugar más fácil para empezar: sin tarjeta de crédito, un servidor de desarrollo con un solo comando y un panel que puedes observar mientras construyes.

El ejemplo es deliberadamente delgado: un agente de atención al cliente que consulta unos cuantos clientes de muestra, redacta una respuesta y emite un reembolso solo después de que un humano lo aprueba. Es delgado a propósito: el agente no es donde reside la dificultad, así que lo mantenemos pequeño y dedicamos el esfuerzo al sistema nervioso que lo rodea. Lo construyes aquí desde cero. Comparte ideas con el curso anterior Digital FTE, pero no asume nada de él. Configura el entorno una sola vez en la Victoria rápida de abajo, y la Parte 4 construye el Worker en siete prompts de pegar y observar. Es un curso primero Python sobre inngest-py: diriges a tu agente de programación en español sencillo y él escribe el código. Si aprendes haciendo, hojea las Partes 1 a 3 y salta a la Parte 4.

El agente y su sistema nervioso. A la izquierda, EL MUNDO llega a través de cuatro señales: cron, webhook, evento y una llamada directa. En el centro, EL PRODUCTION WORKER contiene el sistema nervioso (Inngest, la capa autónoma) que envuelve al agente. El sistema nervioso tiene tres capas numeradas: 1 Sentidos (disparadores), 2 Reflejos (ejecución duradera: step.run, memoización, reintentos) y 3 Equilibrio (control de flujo: concurrencia, throttle, replay). A la derecha, el agente (piensa y actúa, sin cambios) sostiene el OpenAI Agents SDK, habilidades, un servidor MCP, Neon Postgres y un sandbox. El invariante: el agente nunca importa Inngest, así que el sistema nervioso es intercambiable entre Inngest, Temporal, Restate y Dapr.

Por qué un agente de IA necesita un sistema nervioso (cuatro propiedades)

Un solo bloqueo de un agente a mitad de una tarea es molesto. Una fuerza laboral de cincuenta agentes que atienden trabajo de cara al cliente sin un sistema nervioso por debajo es imposible: adoptas una plataforma que te lo brinda o pasas seis meses construyendo tú mismo una versión peor. Cuatro propiedades hacen que este sistema nervioso sea especialmente importante para los agentes:

  1. Cada paso cuesta dinero real. Un reintento ingenuo después de un fallo vuelve a pagar pasos que ya tuvieron éxito; la memoización de pasos (Concepto 7) paga una sola vez.
  2. Los flujos de trabajo acumulan fallos. Un agente de seis pasos con 95 % de confiabilidad por paso tiene un 26 % de probabilidad de fallar en algún punto. La memoización de pasos más los reintentos dirigidos elevan la confiabilidad general a ~99,7 %.
  3. Los efectos secundarios son reales. Los agentes envían correos a clientes, cargan tarjetas y publican en Slack. La memoización de pasos más las claves de idempotencia del proveedor hacen que esas acciones sean seguras.
  4. Los agentes necesitan aprobación humana en momentos de alto riesgo. Sin step.wait_for_event (Concepto 15), tendrías que construir tú mismo una cola de aprobación: tabla de base de datos, sondeo, manejo de tiempos de espera y registro de auditoría. Eso es un proyecto, no una función.

Day AI, el CRM para empresas nativas de IA, ejecuta su producto sobre cada primitiva que enseña este curso: flujos de trabajo duraderos con LLM, coordinación mediante espera de eventos, replay ante fallos, debounce más throttle más concurrencia, y equidad multiinquilino. Dos de sus ingenieros fundadores recurrieron por su cuenta a la misma imagen del sistema nervioso. Es lenguaje de producción, no marca del currículo.

Dónde se ubica este curso en la tesis Agent Factory

La tesis Agent Factory describe siete invariantes que cualquier sistema de agente de producción debe satisfacer. El Worker que construyes aquí satisface el Invariante 4 (un motor) y el Invariante 5 (un sistema de registro, aquí un pequeño registro de auditoría). Este curso agrega dos más, además de una parte del Invariante 1:

  • Invariante 7: el mundo llama al sistema. Los disparadores (programaciones, webhooks, llamadas API entrantes, eventos de otros Workers) despiertan al Worker. Inngest es una implementación.
  • Invariante 1, en parte: el ser humano es el principal. Las puertas de aprobación son el punto donde la intención humana vuelve al runtime. step.wait_for_event es la expresión más limpia en cualquier plataforma: el agente se suspende, una persona emite el evento esperado y el agente se reanuda.
  • Ejecución duradera como invariante implícita en la tesis. La auditoría responde "¿qué pasó?"; la durabilidad responde "hazlo de nuevo desde donde se interrumpió". Se puede reproducir, reintentar y reanudar después de un fallo.

Los 15 conceptos, de un vistazo. Se mapean a los tres trabajos que hace un sistema nervioso: los sentidos (los disparadores despiertan al Worker), los reflejos (la ejecución duradera lo mantiene correcto cuando algo se rompe) y el equilibrio (el control de flujo lo mantiene en buen estado bajo carga). Esta es la versión de primera pasada, el concepto y una idea central en una línea. Cuando algo se rompe durante una construcción, la Referencia rápida del final tiene un diagnóstico de síntoma a concepto que te apunta de vuelta al concepto al que pertenece el fallo.

Los 15 conceptos en una línea cada uno (expande para ver el mapa completo)
#ConceptoEsencia de una línea
Sentidos (Disparadores)cómo el mundo llega al Worker
1Eventos vs solicitudesUna solicitud es sincrónica y alguien espera; un evento es asíncrono y el mundo ya siguió adelante.
2Disparadores cronUn horario despierta la función. Una línea: TriggerCron(cron="0 9 * * *").
3Disparadores de webhookUna carga útil HTTP entrante se convierte en un evento con nombre; tu función reacciona al nombre.
4Idempotencia y semántica de eventosLos ID de eventos y los nombres de los pasos hacen que un evento duplicado (o un reintento) no haga nada.
5Fan-out y delegación de subagentesUn evento, N funciones suscritas; o un padre que activa N eventos hijos.
Reflejos (Ejecución duradera)mantener al Worker correcto cuando algo se rompe
6step.run y el modelo de función duraderaCada step.run es un punto de control; la función puede fallar entre pasos y reanudarse.
7Memoización, la mecánica subyacenteLos pasos completados devuelven la salida almacenada en lugar de volver a ejecutarse.
8step.sleep y step.wait_for_eventAmbos suspenden la función de forma duradera, por un tiempo o por un evento.
9Reintentos, manejo de errores, dead-letterReintentos automáticos con backoff; tras N intentos, la ejecución fallida persiste para replay.
10step.run para llamadas de IA en PythonEnvuelve las llamadas a OpenAI en step.run; step.ai.infer descarga la inferencia (step.ai.wrap es solo de TypeScript).
Equilibrio (Control de flujo)mantener al Worker en buen estado bajo carga
11Concurrencia y throttlingconcurrency limita las ejecuciones activas; throttle limita los inicios por segundo.
12Prioridad y equidadLa prioridad ordena la cola; la concurrencia por clave le da a cada inquilino una parte justa.
13Procesamiento por lotesAcumula eventos en una sola llamada de función por lotes para trabajo masivo de bajo costo.
14Replay y cancelación masivaReproduce ejecuciones fallidas con código nuevo; cancela en masa las ejecuciones que ya no quieres.
15Puertas HITL con step.wait_for_eventLa función se suspende hasta que un humano apruebe, y luego se reanuda con la decisión.

Requisitos previos. Cuatro cosas, y por lo demás el curso se sostiene solo (la Parte 4 construye su propio Worker desde cero).

  1. Sabes dirigir un agente de programación. Claude Code u OpenCode, instalado y autenticado. Modo de plan, archivos de reglas, el flujo de leer primero y luego escribir: si ese ritmo te resulta familiar, estás calibrado. El Curso intensivo de programación agéntica lo cubre si no.
  2. Tienes una OPENAI_API_KEY (u otra clave de modelo que tu agente de programación pueda usar) y una cuenta de Neon para el sistema de registro Postgres del Worker. El Worker ejecuta un modelo real y lee y escribe sus clientes y su registro de auditoría en Neon. Neon es gratis (sin tarjeta), y lo autorizas con un clic en el navegador durante la configuración; regístrate en neon.com en cerca de un minuto si no tienes cuenta. El servidor de desarrollo de Inngest en sí no necesita cuenta.
  3. Tienes Node.js 20+ disponible, aunque el Worker sea Python. El servidor de desarrollo de Inngest se distribuye como una CLI de Node (npx inngest-cli@latest dev).
  4. Tienes un modelo mental funcional de "basado en eventos" frente a "solicitud/respuesta". Si "el mundo emite un evento y cero, una o muchas funciones reaccionan a él" te resulta familiar, estás calibrado. Si no, el Concepto 1 te da la forma.

¿Hiciste De agente a Digital FTE? Tienes un Worker más rico para envolver; una nota al final de la Parte 4 apunta el sistema nervioso hacia él. Es un extra, no un requisito.

Cómo leer esta página en la primera pasada, más un glosario de los términos que encontrarás

Primera pasada. Expande cualquier cosa etiquetada como "Listo cuando" o "A qué prestar atención": comportamiento ejecutable contra el que comparar tus predicciones. En la Parte 4 puedes hojear los fragmentos clave en una primera lectura; la prosa alrededor de cada uno te dice qué hace la capa, y tu agente escribe el código cuando construyes. Los bloques "Prueba con IA" son prompts opcionales de extensión. El objetivo de la primera pasada es tener el modelo del sistema nervioso, sus tres capas, en tu cabeza; la segunda pasada, con las manos en el teclado, es donde construyes. Cada concepto cierra con un Predice (comprométete con una respuesta antes de seguir leyendo) o una Comprobación rápida (pon a prueba la regla que acabas de leer); ambos existen para hacerte pausar, no para calificarte.

Glosario (cada término también se explica en contexto donde aparece por primera vez):

  • Production Worker: un agente de IA con un sistema nervioso a su alrededor: sentidos que lo despiertan (disparadores), reflejos que sobreviven a fallos (ejecución duradera) y equilibrio que lo escala bajo carga (control de flujo).
  • Evento: un mensaje con nombre e inmutable que describe que algo ocurrió. Ejemplo: {"name": "customer/email.received", "data": {"customer_id": "..."}}. Es la superficie de disparo.
  • Función de Inngest: una función Python decorada con @inngest_client.create_function, que declara disparadores y pasos. Es la unidad de trabajo duradero.
  • Paso: una unidad de trabajo dentro de una función de Inngest envuelta en ctx.step.run(), ctx.step.sleep(), ctx.step.wait_for_event() o ctx.step.ai.infer(). Cada paso se reintenta y se memoiza de forma independiente.
  • Memoización: cuando una función falla y se reinicia, Inngest vuelve a ejecutar el código de la función desde arriba, pero devuelve salidas almacenadas para cualquier step.run cuyo resultado ya esté en caché. La función alcanza el punto donde se interrumpió sin rehacer el trabajo.
  • Control de flujo: políticas por función: concurrency (ejecuciones activas máximas), throttle (inicios máximos por segundo), priority (orden de cola), batch_events (acumular antes de invocar).
  • HITL (humano en el bucle): una función se detiene a esperar la aprobación o entrada humana antes de continuar. step.wait_for_event es la primitiva.
  • Replay: volver a ejecutar ejecuciones fallidas como ejecuciones nuevas desde arriba, sobre el código actual tras corregir un error (distinto del reintento automático dentro de una ejecución, que se reanuda desde la memoización). Es el botón Rerun del panel.
  • Servidor de desarrollo: el entorno de desarrollo local de Inngest mediante npx inngest-cli@latest dev. Panel en http://127.0.0.1:8288; endpoint MCP en /mcp.
Vigencia

Actual a mayo de 2026. Toda la construcción de la Parte 4 se ejecutó de extremo a extremo contra un servidor de desarrollo de Inngest en vivo y un modelo real en inngest 0.5.18, openai-agents 0.17.3, fastapi 0.136.3, Python 3.12 y la CLI de Inngest. Cada fragmento de la Parte 4 proviene de esa construcción funcional, no se escribió de memoria. La arquitectura que enseña este curso no cambia cuando lo hace el SDK; el SDK es la interfaz de este año hacia ella. Si una página de documentación en vivo y esta página alguna vez difieren en un detalle de sintaxis, gana la documentación: fija tus versiones y consulta el inicio rápido de Inngest en Python y la documentación del OpenAI Agents SDK cuando construyas.

Elige tu herramienta, la página te sigue

Las secciones que divergen entre Claude Code y OpenCode tienen un conmutador; elige uno y la página se sincroniza entre visitas.


La victoria rápida en quince minutos: configura la base y observa el reflejo

Antes de leer los 15 conceptos que explican por qué funciona esta arquitectura, configura el entorno en el que corre todo el curso y observa cómo una función duradera sobrevive a un fallo. Esta es la configuración que haces una sola vez; la Parte 4 construye el Worker de atención al cliente sobre exactamente la misma base. Al final tendrás:

  • la base abierta en tu agente de programación, las habilidades instaladas y tres servidores MCP conectados (Neon, Context7 y el inngest-dev del servidor de desarrollo),
  • una base de datos Neon nueva con dos tablas, customers y audit_log, que creaste por MCP y viste en la consola, con su DATABASE_URL escrita en .env para que el Worker la use más tarde,
  • una función duradera diminuta (un step.run, un step.sleep, un host de FastAPI) ejecutándose contra el servidor de desarrollo de Inngest,
  • una ejecución que disparaste y observaste suspenderse en el sleep con cero cómputo consumido,
  • y una ejecución que rompiste a propósito, y luego observaste cómo Inngest la reintentaba, devolviendo el paso ya completado desde la memoización mientras solo el paso roto se volvía a ejecutar.

Ese último momento es el punto central del curso, en miniatura: el reflejo que puedes ver con tus propios ojos, un paso falla y el sistema se recupera sin rehacer el trabajo que ya había terminado. Este no es el ejemplo trabajado de la Parte 4 (el Worker completo, siete prompts); esto es de una sentada. Hazlo y luego vuelve por los conceptos.

Un Production Worker son dos procesos uno al lado del otro, y mantenerlos diferenciados es el modelo mental: un host de funciones Python (tu código, que sirve la función a Inngest) y el servidor de desarrollo de Inngest (el sistema nervioso que dispara ejecuciones, memoiza pasos y te muestra el panel). Tu agente de programación conecta ambos, instala las habilidades que le enseñan los patrones de Inngest y habla con el servidor de desarrollo a través del MCP inngest-dev.

Importa un límite más, y es el mismo que trazó el curso Digital FTE. Tu Worker mantiene sus clientes y su registro de auditoría en una base de datos Neon Postgres, y hay dos formas distintas en que esa base de datos se toca. Tu agente de programación usa el MCP de Neon para construirla e inspeccionarla: crear las tablas, leer filas, obtener la cadena de conexión, todo en español sencillo en tiempo de desarrollo. Tu Worker usa su propia conexión de Postgres (DATABASE_URL) para leerla y escribirla en tiempo de ejecución. El Worker nunca llama al MCP de Neon, y la propia documentación de Neon es clara sobre el porqué: el servidor MCP es para desarrollo e inspección, nunca conectado a una aplicación en ejecución. Neon es gratis con un clic de OAuth; el servidor de desarrollo de Inngest no necesita ninguna cuenta.

Obtén la base y ábrela

Descarga la base y abre la carpeta en tu agente de programación. El agente hace la configuración por sí mismo, a partir de los prompts de más abajo. Esto lo configuras una sola vez: ai-agent-nervous-system/ es tu carpeta para todo el curso, tanto la Victoria rápida como la Parte 4. Nunca vuelves a descargar ni a descomprimir.

Descarga ai-agent-nervous-system-base.zip

cd ai-agent-nervous-system
claude

Esta base asume un agente general capaz (Claude Code, u OpenCode ejecutando Claude Sonnet u Opus, GPT-5 o similar). Un modelo más pequeño se desviará en el prompt de construcción; si su primer plan se ve vago en lugar de específico, cambia a uno más fuerte antes de continuar.

Prepara la base (~3 min)

La base trae sus reglas en AGENTS.md y su conexión de MCP; las habilidades, tu clave y la autorización de Neon vienen después. Haz que tu agente se configure a sí mismo. Pega esto:

Lee AGENTS.md y luego deja esta base lista: instala las habilidades que indica para el agente que seas, copia .env.example a .env por mí, y dime exactamente qué necesitas de mí para poner en línea los servidores MCP de Neon y Context7.

A qué prestar atención: el agente instalando las cuatro habilidades de Inngest y la habilidad neon-postgres (ves las ejecuciones de instalación y las confirmaciones Installed), creando .env, y luego pidiéndote dos cosas: tu OPENAI_API_KEY para pegar en .env, y un clic en el navegador para autorizar Neon por OAuth. Neon es gratis; si aún no tienes cuenta, regístrate en neon.com en cerca de un minuto, o crea una directamente en la pantalla de autorización. INNGEST_DEV=1 ya está en .env, así que el SDK corre en modo de desarrollo local sin clave de firma. Cuando la instalación y el cableado estén listos, el agente te dice que inicies el servidor de desarrollo (siguiente paso) y luego lo reinicies, porque las nuevas habilidades y el MCP inngest-dev no se cargan a mitad de sesión.

Listo cuando: las habilidades están instaladas, .env tiene tu clave, Context7 es accesible y Neon está autorizado. El MCP inngest-dev se pone en línea una vez que el servidor de desarrollo está corriendo, que es el siguiente paso.

Inicia el servidor de desarrollo y confirma que el agente puede alcanzarlo (~2 min)

Este curso agrega dos límites que tu agente alcanza por MCP: una base de datos Neon que construye e inspecciona, y el servidor de desarrollo en ejecución al que envía eventos y observa. Así que antes de construir nada, pon ambos en marcha y confirma que están en vivo.

Inicia el servidor de desarrollo de Inngest en su propia terminal (es una CLI de Node; déjalo corriendo):

npx inngest-cli@latest dev

El panel aparece en http://127.0.0.1:8288, y el servidor de desarrollo expone su endpoint MCP en /mcp. Ahora reinicia tu agente de programación (sal y relánzalo en la carpeta ai-agent-nervous-system) para que se carguen tanto las habilidades recién instaladas como el MCP inngest-dev. Luego pega esto:

Lista las herramientas de Neon y las herramientas de inngest-dev que puedas ver.

A qué prestar atención: dos listas reales. Las herramientas de Neon (crear un proyecto, ejecutar SQL, describir tablas, obtener una cadena de conexión y demás) son la mano de tu agente sobre la base de datos. Las herramientas de inngest-dev (list_functions, send_event, invoke_function, get_run_status y el resto) son su mano sobre el servidor de desarrollo en ejecución. Todo lo de abajo se apoya en ambas.

Puerta abierta: la respuesta lista nombres reales de herramientas de Neon y nombres reales de herramientas de inngest-dev. Si faltan las herramientas de Neon: el OAuth no terminó; rehaz la autorización de Neon del paso de preparación. Si faltan las herramientas de inngest-dev: el servidor de desarrollo no está corriendo (inícialo), o te saltaste el reinicio (sal, relanza en esta carpeta, pregunta de nuevo).

Construye el almacén y obtén su cadena de conexión (~3 min)

Ahora crea el sistema de registro del Worker por el MCP de Neon, y luego entrégale al Worker lo único que necesitará para alcanzarlo más tarde: una cadena de conexión. El Worker que construyes en la Parte 4 lee aquí sus clientes y escribe aquí su registro de auditoría. Pega esto:

Pega esto a tu agente de programación. Planifica primero; ejecuta tras la aprobación.

En un proyecto Neon nuevo, crea dos tablas: customers (id, email, tier) y audit_log (un registro de cada acción que toma el Worker). Luego llama a la herramienta de Neon que devuelve la cadena de conexión y escribe esa URL en mi .env como DATABASE_URL. Usa las herramientas de Neon para todo; no me escribas SQL para que yo lo ejecute.

A qué prestar atención: el agente llamando a las herramientas del MCP de Neon para crear el proyecto y las dos tablas (ves esas llamadas de herramienta, no SQL que tú escribiste), y luego escribiendo DATABASE_URL en .env. Esa cadena es la entrega: el MCP de Neon aprovisionó el almacén, y tu Worker usará la cadena, no el servidor MCP.

Listo cuando: existe un proyecto Neon nuevo con una tabla customers y una tabla audit_log, y .env tiene una DATABASE_URL. Abre console.neon.tech, elige el proyecto que el agente acaba de crear y abre Tables: ahí están customers y audit_log, vacías por ahora. Verás aparecer filas en D0 cuando el Worker se ejecute. (Una tabla es solo una hoja de cálculo: cada fila una cosa, cada columna un detalle.)

Construye la primera función duradera y dirígela desde el panel (~3 min)

Ahora construye la función duradera más pequeña, usando las habilidades que acabas de instalar. Las habilidades de Inngest son primero TypeScript en sus ejemplos, así que tu agente toma de ellas los patrones (qué es un paso, cómo está conformada una función duradera) y confirma las firmas exactas de Python desde la documentación (el grep_docs/read_doc del MCP del servidor de desarrollo, o Context7), no de memoria. Pega esto:

Usando las habilidades de Inngest, escribe una función duradera de Inngest diminuta (llámala greet-customer, disparada por un evento demo/greet) que componga un saludo en un step.run, duerma quince segundos con step.sleep, luego componga una despedida en un segundo step.run y devuelva ambos. Sírvela desde un host de FastAPI en modo de desarrollo local, e inicia el host en el puerto 8000 con recarga automática activada, para que los cambios que haga después se recojan sin reinicio manual.

La forma que escribe, para que la reconozcas cuando la veas: la función es un async def simple, las dos llamadas step.run envuelven trabajo que debe memoizarse, y el step.sleep entre ambas suspende la ejecución de forma duradera (el proceso puede fallar, reiniciarse o redesplegarse durante el sleep; la ejecución se reanuda en la línea siguiente cuando se activa el temporizador). Un detalle a confirmar en el código del agente: el cliente de Inngest se construye con is_production=False, o lee el INNGEST_DEV=1 que ya está en tu .env. Sin uno de los dos, el SDK toma por defecto Cloud silenciosamente y tu función nunca se registra localmente.

Listo cuando: el host de funciones está corriendo en el puerto 8000, y el servidor de desarrollo (que ya corría del paso anterior) lo descubrió automáticamente. Abre http://127.0.0.1:8288, haz clic en Functions, y greet-customer aparece en la lista. El resto lo diriges desde el navegador.

Dispárala y observa un paso dormir con cero cómputo (tú diriges)

Envía el evento disparador. La ruta más simple es el panel: en http://127.0.0.1:8288, haz clic en Events, luego en Send event, pega esto y haz clic en Send:

{
"name": "demo/greet",
"data": { "name": "Sara" }
}

(¿Prefieres quedarte en el agente? Pídele que envíe el evento por el MCP: "Envía un evento demo/greet con name Sara usando la herramienta send_event de inngest-dev." De cualquier forma inicia la misma ejecución.)

Haz clic en Runs y abre la nueva ejecución. El primer paso se completa; el paso sleep muestra Sleeping con una hora de reanudación. Nada en tu código está corriendo, la terminal del host está inactiva, y ese es el punto: una espera duradera cuesta cero cómputo. Tras quince segundos la ejecución se reanuda por sí sola, el paso de despedida se completa y el estado cambia a Completed. El panel Output muestra el diccionario devuelto.

Rompe un paso y observa el reintento saltarse el trabajo que ya hizo (la recompensa)

Ahora haz que un paso falle a propósito, para que puedas observar cómo la memoización lleva el trabajo completado a través del reintento. Pega esto a tu agente:

Haz que el paso de despedida lance un error a propósito, para que pueda ver fallar una ejecución. Deja todo lo demás igual.

Envía el mismo evento demo/greet otra vez, luego abre la ejecución y lee su traza. Aquí está la recompensa, y está en esta única ejecución fallida: el paso de saludo muestra un intento completado, y el paso de despedida muestra varios Attempts, cada uno reintentado con backoff (Inngest usa por defecto varios intentos) antes de que la ejecución termine en Failed. Detente en lo que significa ese conteo de intentos: el paso de saludo completado se paga una sola vez, no una por reintento. Esa es la ejecución duradera que puedes ver con tus propios ojos. Por qué el paso completado se devuelve al instante en lugar de volver a ejecutarse es la mecánica que conocerás en el Concepto 7; por ahora, solo observa cómo sucede.

(Esta construcción del servidor de desarrollo no muestra una insignia "memoized" aparte. La memoización es el conteo de intentos: el paso completado quieto en un intento mientras el paso roto sube es exactamente cómo se ve aquí "devuelto desde la memoización, no vuelto a ejecutar".)

Ahora arréglalo:

Ahora revierte el paso de despedida a la versión que funcionaba.

El host se recarga automáticamente (eso es lo que te compró --reload; si te lo saltaste, reinicia el host a mano). Envía un evento demo/greet nuevo y la función completa ahora corre limpia hasta Completed sobre el código corregido. Una nota honesta sobre la recuperación, porque a la gente le muerde: el botón Rerun del panel inicia una ejecución nueva desde arriba con tu código actual, y cada paso se vuelve a ejecutar desde cero. Esa es la herramienta correcta para la recuperación de incidentes (un mal despliegue rompió un lote de ejecuciones; envías un arreglo y las vuelves a ejecutar), pero no es la reanudación que preserva la memoización. La reanudación que preserva la memoización es el reintento automático que acabas de observar dentro de la ejecución fallida, donde el paso completado se quedó en su lugar.

Acabas de configurar todo el entorno del curso y viste el sistema nervioso funcionar con tus propios ojos: las habilidades están instaladas, tu almacén de Neon está aprovisionado con DATABASE_URL en .env, el MCP del servidor de desarrollo está en vivo, y ejecutaste una función duradera, observaste un paso dormir sin consumir cómputo, luego rompiste un paso y observaste el reintento automático devolver el paso completado desde la memoización mientras solo el roto se volvía a ejecutar. Esa es la arquitectura de la que trata este curso. El resto del curso la escala: sentidos reales (cron, webhook, fan-out), reflejos más fuertes (la invocación del agente dentro de step.run), equilibrio real bajo carga, y la puerta de aprobación humana que convierte "el agente podría estropear esto" en "el agente redacta, un humano aprueba, la acción se emite".

Si algo no funcionó, cuatro problemas cubren casi todo:

  1. El servidor de desarrollo no puede alcanzar el host de funciones: confirma que el host está corriendo en el puerto 8000.
  2. El cliente está en modo Cloud: el agente omitió is_production=False y a .env le falta INNGEST_DEV=1, así que las funciones nunca se registran localmente. Pídele que ponga uno (un valor explícito de is_production gana sobre la variable de entorno).
  3. La función falta en el panel: el host no se recargó; reinícialo.
  4. Una ejecución se cuelga sin error y sin progreso: un host desincronizado se estanca en silencio; reinicia juntos el host y el servidor de desarrollo, y ejecuta un host contra un servidor de desarrollo. (Una causa sutil: si :8288 estaba ocupado y el servidor de desarrollo arrancó en 8289+, reapuntar la URL del MCP inngest-dev no basta; el host sigue hablando con :8288. Pon INNGEST_BASE_URL=http://127.0.0.1:<port> en el host para que siga al servidor de desarrollo al nuevo puerto.)

Si caes en alguno de estos, el movimiento de recuperación universal también funciona aquí: "Algo no funcionó. Lee el error, dime en español sencillo qué ves, y propón un arreglo que yo pueda aprobar."

Lo que construiste, y hacia dónde crece

El entorno está configurado: la base está abierta, las habilidades están instaladas, los tres servidores MCP están conectados (Neon, Context7, inngest-dev), tu almacén de Neon tiene sus tablas customers y audit_log con DATABASE_URL en .env, y el servidor de desarrollo está corriendo. También viste la única idea sobre la que descansa todo el curso, el reflejo de la ejecución duradera, con tus propios ojos. La Parte 4 construye el Worker de atención al cliente sobre esta misma base, en esta misma carpeta: lee esos clientes y escribe esas filas de auditoría, y luego envuelve todo en el sistema nervioso completo, un disparador de evento real, un cron diario que hace fan-out, control de flujo y la puerta duradera de aprobación humana sobre los reembolsos. La Parte 4 escala este esqueleto de step.run y step.sleep en un Worker que hace trabajo real sobre tu almacén de Neon. Si esta Victoria rápida funcionó, los conceptos que siguen explican por qué cada pieza tiene esta forma.


Parte 1: los sentidos, cómo el mundo llega al Worker

Un agente de IA al que llamas a mano corre cuando lo llamas. Un Production Worker real tiene sentidos: corre cuando el mundo lo alcanza. Un cliente envía un correo, llega un webhook, un cron se activa a las 09:00 todos los días, otro Worker delega trabajo. Cada uno de estos es una señal que entra, y un disparador es cómo el agente la siente. Los cinco conceptos de la Parte 1 son esos sentidos: el modelo mental basado en eventos, las tres formas en que el mundo entra (cron, webhook, evento), la semántica que evita el doble procesamiento, y los patrones de fan-out que permiten que una señal despierte a muchos Workers.

Concepto 1: eventos vs solicitudes, el cambio duradero del modelo mental

Todo lo que sigue en este curso descansa en un cambio mental: de solicitudes a eventos.

Una solicitud es una conversación sincrónica. Alguien llama; tú gestionas; devuelves una respuesta; la otra parte continúa. Una conexión permanece abierta; una persona o servicio está esperando. Si fallas, quien llama recibe un error. Un agente con el que conversas en el prompt es una solicitud: escribiste, transmitió la respuesta, la conversación pertenecía a tu sesión de terminal.

Un evento es un mensaje asincrónico. Algo sucedió en el mundo (un cliente se registró, llegó un correo, se liquidó un pago) y quien lo origina emite un registro con nombre de ese hecho. Cero, una o muchas funciones reaccionan al evento de forma independiente. Ninguna conexión permanece abierta. Quien lo origina no sabe quién está escuchando, no espera resultados y no está bloqueado. El mundo ya siguió adelante.

# A request: I'm here, waiting, blocking
result = await agent.handle_customer_message(text=user_input)
print(result) # I unblock when the agent finishes

# An event: I fire-and-forget
await inngest_client.send(events=[
inngest.Event(
name="customer/email.received",
data={"customer_id": "c-4429", "body": email_body, "subject": subject},
),
])
# I return immediately. Somewhere else, one or more Inngest
# functions react to this event on their own schedule.

Solicitud frente a evento. El modelo de solicitud (arriba, rojo) es sincrónico, bloqueante y frágil: un productor emite una solicitud y espera en una conexión abierta mientras el consumidor corre unos ocho segundos; un fallo pierde el trabajo, y absorber cincuenta solicitudes por minuto necesita unos siete manejadores en paralelo. El modelo de eventos (abajo, verde) es asíncrono, duradero y aislado: un productor emite un evento y vuelve en unos diez milisegundos a un registro de eventos duradero, que hace fan-out hacia consumidores independientes (un agente, un contador de analítica, un detector de VIP); haz fallar a un consumidor y el evento espera en el registro y se reintenta. El evento es la señal; una vez almacenado, el Worker reacciona en su propio tiempo.

El cambio suena pequeño. No lo es. Una vez que piensas en eventos, la durabilidad y la escala salen casi gratis, porque:

  • El productor no puede ser frenado por el consumidor (quien recibe el correo no espera a que el agente termine de redactar una respuesta).
  • El consumidor puede fallar y reiniciarse sin perder el trabajo (el evento se almacena de forma duradera; Inngest lo vuelve a entregar).
  • Se pueden agregar nuevos consumidores sin cambiar a los productores (una segunda función, por ejemplo un contador de analítica, puede suscribirse a customer/email.received sin que quien recibe el correo lo sepa).
  • La contrapresión se convierte en una política de control de flujo, no en un cambio de código (Inngest limita la concurrencia; el productor sigue emitiendo; los eventos se encolan).

Predice. Tu Worker de atención al cliente tarda 8 segundos en responder un correo: tres segundos para el razonamiento del agente, cuatro segundos para dos llamadas a herramientas MCP, un segundo para la escritura en base de datos. En carga máxima recibes 50 correos por minuto. Si usas el modelo de solicitud (el analizador de correo se bloquea hasta que el agente termina), ¿cuántas conexiones HTTP paralelas hacia tu analizador implica eso? Si usas el modelo de eventos (el analizador emite un evento y vuelve de inmediato), ¿cuántas? Confianza 1-5.

La respuesta: el modelo de solicitud necesita unos 7 analizadores simultáneos (50/min × 8 segundos = ~6,7 manejadores en paralelo, más margen). El modelo de eventos necesita un analizador (emite el evento y vuelve en ~10 ms; la cola de eventos absorbe el pico de 50/min; las funciones de Inngest consumen la cola con la concurrencia que permitas). El modelo de eventos desacopla la tasa de producción de la tasa de consumo. Esto no es solo un dato de escala; es uno arquitectónico. El evento se convierte en un límite duradero entre "lo que ocurrió en el mundo" y "lo que el Worker hace al respecto". Haz fallar al consumidor a mitad del procesamiento y el evento sigue ahí para reintentarse. Agrega tres tipos de consumidores más y el productor ni se entera. Los eventos son la forma de dejar de ser dueño del momento del trabajo.

Prueba con IA
Walk me through three scenarios. For each, classify it as REQUEST-MODEL
or EVENT-MODEL, and explain which one fits better:

A) A user clicks "Submit refund request" in the support portal and
expects to see "Refund issued: $30" within 2 seconds.

B) A nightly cron job at 02:00 runs a customer-health-check across
all 5,000 customers and writes a report to Slack.

C) A customer sends an email to support@; we want a draft response
ready within 60 seconds for the on-call agent to review and send.

For each, name (a) what the human's expectation of timing is and
(b) what failure looks like if the model crashes mid-execution.

Concepto 2: disparadores cron, trabajo que corre porque pasó el tiempo

El disparador más simple es el reloj. Muchas cosas que hace un Production Worker no son reacciones a eventos externos; son trabajo programado: informes de salud diarios, limpiezas semanales, recálculos horarios. El disparador cron de Inngest es una línea de código.

import inngest

@inngest_client.create_function(
fn_id="daily-customer-health-check",
trigger=inngest.TriggerCron(cron="0 9 * * *"), # 09:00 every day, UTC
)
async def daily_health_check(ctx: inngest.Context) -> dict[str, int]:
"""Run a customer-health pass for every Pro/Enterprise customer."""
customers = await ctx.step.run("fetch-pro-customers", fetch_pro_customer_ids)

# fan out: one event per customer, one Worker run per event
await ctx.step.run("fan-out", fan_out_per_customer_events, customers)

return {"customers_scheduled": len(customers)}

Tres cosas a notar:

  • El horario es solo sintaxis cron estándar. 0 9 * * * son las 09:00 UTC todos los días; */15 * * * * es cada 15 minutos; 0 9 * * 1 es los lunes a las 09:00. Inngest evalúa el cron en UTC; si necesitas otra zona horaria, eso es un parámetro de función, no un concepto distinto.

  • La función sigue usando ctx.step.run. Disparada por cron o por evento, la forma de la función es idéntica. Los pasos funcionan igual. La durabilidad funciona igual. El control de flujo funciona igual. El disparador es solo cómo se inicia la función.

  • La salida del cron es una ejecución normal de una función de Inngest. Aparece en el panel, tiene un ID de ejecución, tiene una traza, admite replay. Si la ejecución del cron del lunes por la mañana falla en el paso 3, el cron del martes correrá normalmente y el fallo del lunes quedará disponible para replay tras corregir el error.

¿Qué pasa si tu servicio está caído cuando se activa el cron? Esta es la pregunta que separa a un programador duradero de uno frágil. Las ejecuciones cron de Inngest se registran de forma duradera en el momento en que se activa el horario; si no se puede alcanzar el endpoint de tu función, Inngest reintenta con backoff hasta que tenga éxito o alcance el techo de reintentos. El cron activado a las 09:00 no se "pierde" porque tu despliegue estaba en curso a las 09:00; la ejecución espera, terminas el despliegue y la ejecución se completa. Los disparadores cron en desarrollo tienen una peculiaridad que vale la pena conocer: el servidor de desarrollo local solo activa crons mientras está corriendo. Producción los ejecuta en la infraestructura de Inngest, que siempre está activa.

Comprobación rápida. Tres afirmaciones. Marca cada una como Verdadera o Falsa. (a) Si una función cron tarda 45 minutos en correr y se programa cada 15 minutos, habrá tres instancias concurrentes corriendo en un momento dado. (b) Puedes usar step.sleep dentro de una función disparada por cron para repartir el trabajo a lo largo del día. (c) Una función disparada por cron también se puede invocar manualmente desde el panel para hacer pruebas.

Respuestas: (a) Depende de la política de concurrencia: por defecto Inngest encolará las ejecuciones superpuestas; si pones concurrency=1 se serializan; si pones concurrency=10 se paralelizan. El valor por defecto es sensato. (b) Verdadero, y es un patrón común para "repartir el trabajo diario en varias horas para suavizar la carga". (c) Verdadero: el panel de Inngest permite invocar cualquier función bajo demanda para pruebas, sin importar su disparador.

Prueba con IA
With my AI coding assistant connected to the Inngest dev server MCP,
write a cron-triggered Inngest function in Python that:

1. Runs every Monday at 09:00 UTC.
2. Queries the audit_log table for all conversations resolved in the
prior week (status='resolved' in that window).
3. Computes per-agent metrics: total conversations resolved, average
resolution time, count of escalations, count of refunds issued.
4. Returns the metrics as a JSON object.

After you write the function, use the MCP's `invoke_function` tool to
test it manually (instead of waiting for Monday). Confirm the audit
SQL is correct by using `grep_docs` to search Inngest's docs for
"step.run" examples.

Concepto 3: disparadores de webhook, cuando el mundo exterior llama

La segunda superficie de disparo es HTTP. Un sistema externo (Stripe, tu proveedor de correo, un formulario del portal de clientes, un webhook de GitHub) quiere llamar a tu Worker. Sin Inngest, tendrías que: levantar un endpoint HTTPS, analizar la carga útil, validar la fuente, escribir en una cola, escribir un Worker que consuma de la cola, manejar reintentos, manejar idempotencia, enviar telemetría. Cada uno es una semana de trabajo de infraestructura.

Con Inngest, el endpoint ya viene incluido. Configuras un webhook en el panel de Inngest con una URL como https://inn.gs/e/<your-key>, apuntas Stripe (o lo que sea) a esa URL, y la carga útil del webhook se convierte en un evento en tu flujo de eventos. Cualquier función con un disparador de nombre de evento coincidente se activa ahora.

@inngest_client.create_function(
fn_id="handle-stripe-refund-failed",
trigger=inngest.TriggerEvent(event="stripe/charge.refund.failed"),
)
async def on_refund_failed(ctx: inngest.Context) -> dict[str, str]:
"""Triggered by Stripe webhook → Inngest event → this function."""
charge_id = ctx.event.data["charge_id"]
customer_id = ctx.event.data["customer_id"]

# Look up which support ticket originated this refund
ticket = await ctx.step.run(
"find-ticket-for-refund", lookup_ticket_by_charge, charge_id,
)

# Wake the customer-support Worker with the full context
await ctx.step.run(
"notify-support-agent",
notify_support_agent_of_refund_failure,
ticket_id=ticket["id"], charge_id=charge_id,
)

return {"ticket": ticket["id"], "action": "notified"}

El flujo: Stripe no puede reembolsar un cargo → Stripe hace POST a la URL del webhook de Inngest → Inngest crea un evento llamado stripe/charge.refund.failed → la función de arriba (que coincide con ese nombre de evento) se activa → la función usa pasos para buscar el ticket y notificar al agente de soporte. Nada del cableado HTTP es tuyo de escribir. Sin endpoint, sin analizador, sin cola, sin consumidor.

Dos patrones adyacentes que vale la pena nombrar:

  • Webhooks JSON genéricos. Si la fuente no es un proveedor conocido, apuntas cualquier servicio que emita JSON al mismo tipo de endpoint y eliges el nombre del evento. Los nombres con namespaces separados por barra (vendor/event.subtype) son la convención; nada la impone, pero el panel ordena de forma limpia cuando la sigues.
  • Transformaciones de webhook. Si la carga útil entrante no coincide con la forma que quieres, Inngest permite definir una función de "transformación" que corre del lado del servidor al recibirse y reformatea el evento antes de que entre en tu flujo de eventos. Esto mantiene el código de tu función libre de campos específicos del proveedor.

Predice. Un webhook de Stripe activa stripe/charge.refund.failed exactamente en el mismo milisegundo en que tu Worker de atención al cliente también llama a inngest_client.send para emitir un evento distinto llamado customer/refund.investigation_needed. Ambos eventos llegan al sistema a la vez; la función de arriba se activa solo con el evento de Stripe. ¿La función correrá una o dos veces? Confianza 1-5.

La respuesta: una vez. La función está registrada para activarse solo con stripe/charge.refund.failed; el evento customer/refund.investigation_needed tiene otro nombre y coincide con otra función (o con ninguna, si no escribiste una). El nombre de un evento es su clave de enrutamiento. Dos eventos con nombres distintos nunca activan accidentalmente la misma función, aunque lleguen en el mismo instante. Por eso importa la disciplina de nombres: un error tipográfico en el nombre de un evento (customer/email_received vs customer/email.received) significa que la función nunca se activa, y el síntoma es silencioso. El panel de Inngest ayuda a detectarlo: los eventos no coincidentes aparecen en un flujo aparte que puedes auditar.

Prueba con IA
I need to handle three webhook sources for my customer-support Worker:

A) Stripe: refund failed, charge disputed
B) Postmark (email service): bounced email, complaint
C) My internal admin UI: manual "investigate this ticket" button

For each, decide:

1. What event names you'd use (vendor/event.subtype format).
2. Whether the function reacting to it should run synchronously (the
caller is waiting) or asynchronously (fire and continue).
3. Whether you'd write a webhook transform to reshape the payload, or
consume it raw.

Then write the Inngest function for the Stripe refund-failed case in
Python, using the MCP's grep_docs to find the current syntax for
TriggerEvent and the dev-server MCP's send_event tool to test it.

Concepto 4: idempotencia y semántica de eventos, el mismo evento disparándose dos veces

Los webhooks no son exactamente-una-vez. Son al-menos-una-vez: el remitente reintenta si no recibe un acuse de recibo. Las redes descartan paquetes, los servicios se reinician, tu endpoint agota el tiempo de espera y el remitente reintenta aunque en realidad hayas tenido éxito. Sin idempotencia, todo sistema de webhooks acaba facturando, enviando correos o reembolsando dos veces a alguien. No es una preocupación teórica; es el error de producción más común en los sistemas de eventos.

Dos capas de defensa, ambas integradas en Inngest.

Capa 1: semilla de ID de evento en el origen. Cuando tú mismo envías un evento (en lugar de recibirlo de un webhook), puedes adjuntar una clave de idempotencia:

await inngest_client.send(events=[
inngest.Event(
name="customer/refund.requested",
data={"order_id": "o-4429", "amount_cents": 5000},
id=f"refund-request-{order_id}-{request_timestamp}", # idempotency key
),
])

Si se envía un segundo evento con el mismo id dentro de la ventana de deduplicación (24 horas por defecto), Inngest descarta el duplicado. Mismo evento lógico, mismo id, una sola ejecución de función.

Capa 2: idempotencia a nivel de paso. Dentro de una función, cada step.run se identifica por su nombre. Si una función falla entre el paso 3 y el paso 4, el reintento vuelve a ejecutar el código de la función desde arriba, pero para los pasos 1, 2 y 3, Inngest devuelve las salidas almacenadas sin volver a ejecutar el cuerpo del paso. El paso 4 corre normalmente por primera vez. Esto es lo que hace que una función sea "duradera": los efectos secundarios de los pasos completados no vuelven a ocurrir al reintentar.

@inngest_client.create_function(
fn_id="issue-customer-refund",
trigger=inngest.TriggerEvent(event="customer/refund.requested"),
)
async def issue_refund(ctx: inngest.Context) -> dict[str, str]:
# Step 1: look up the order. If the function retries, this returns
# the SAME order data it computed the first time, from Inngest's memo.
order = await ctx.step.run(
"lookup-order", lookup_order_by_id, ctx.event.data["order_id"],
)

# Step 2: call Stripe. If the function retries AFTER this step
# succeeded, the Stripe call does NOT happen again. The refund is
# issued exactly once even if the function runs three times.
refund = await ctx.step.run(
"issue-stripe-refund", call_stripe_refund_api,
charge_id=order["stripe_charge_id"],
amount=ctx.event.data["amount_cents"],
)

# Step 3: write the audit row. Same property: runs at most once.
await ctx.step.run(
"audit-refund", write_audit_refund_issued,
order_id=order["id"], refund=refund,
)

return {"refund_id": refund["id"]}

Si esta función falla durante el paso 3, el reintento vuelve a entrar al paso 1 (obtiene los datos del pedido en caché, sin llamada a la base de datos), vuelve a entrar al paso 2 (obtiene los datos del reembolso en caché, sin llamada a Stripe), corre el paso 3 de verdad y retorna. La tarjeta del cliente se carga una vez, aunque la función haya corrido tres veces. Esta es la propiedad central. Es lo que hace a Inngest cualitativamente distinto de una cola con un bucle de reintento.

Exactamente-una-vez en el límite externo necesita ambas capas

La memoización de Inngest te da la terminación de paso exactamente-una-vez desde la perspectiva de la función: una vez que step.run registra un paso como exitoso, no se vuelve a ejecutar. Pero hay una ventana estrecha. Si el cuerpo de tu paso llama a Stripe (el efecto secundario ocurre en los servidores de Stripe) y luego falla antes de que Inngest registre el resultado, el reintento volverá a llamar a Stripe. Desde la perspectiva de Inngest, el paso "no se completó". Desde la perspectiva de Stripe, el cargo ya ocurrió. El patrón de grado de producción es memoización de pasos en Inngest más claves de idempotencia a nivel de proveedor: el encabezado Idempotency-Key de Stripe, la reutilización de MessageID de Postmark, el contrato de idempotencia de tu propio servidor MCP. Trata step.run y las claves de idempotencia del proveedor como complementarias, no como sustitutas: step.run mantiene la lógica interna de tu función exactamente-una-vez; la clave de idempotencia del proveedor mantiene el efecto secundario externo exactamente-una-vez.

Comprobación rápida. Verdadero o falso. (a) step.run hace que el paso sea idempotente solo si la función interna también es idempotente. (b) Un evento con un ID duplicado fuera de la ventana de deduplicación se tratará como un evento nuevo. (c) Si step.run falla a mitad de la ejecución (el código del paso lanza una excepción), Inngest almacena el fallo y reintenta el paso en el siguiente intento sin volver a ejecutar los pasos anteriores.

Respuestas: (a) Falso: step.run hace que la invocación del paso sea idempotente (correrá como máximo una vez si tiene éxito), pero si la función interna no es idempotente (como llamar a Stripe), esa garantía de como-máximo-una-vez es exactamente lo que quieres. El punto es que no tienes que hacer idempotente por tu cuenta la llamada a Stripe. (b) Verdadero: la ventana de deduplicación de Inngest es de 24 horas por defecto; los eventos con el mismo ID después de esa ventana se tratan como nuevos. (c) Verdadero: el reintento automático conserva la memoización; Inngest sabe que el paso 3 falló en el intento 1 y reintenta solo el paso 3 en el intento 2. Los pasos anteriores exitosos no se vuelven a ejecutar. (Este es el reintento dentro de la ejecución, no el botón Replay del panel, que es una ejecución nueva, Concepto 14.)

Prueba con IA
Here are three scenarios. For each, decide: idempotency PROBLEM or
NO PROBLEM, and if it's a problem, what's the fix:

A) Stripe sends the same charge.refund.failed webhook three times
in 90 seconds (because their first two attempts timed out at
your endpoint). Your function emails the customer.

B) A customer clicks "Issue refund" three times because the page
was slow. Your function calls Stripe and writes audit_log.

C) Your nightly cron at 09:00 sends a customer-health-check event
to each Pro customer. If two crons fire at the same time (a deploy
bug), what happens?

For each problem case, propose ONE specific fix: event ID seed
inside the function, idempotency key in inngest_client.send, or
function-level deduplication on the trigger.

Concepto 5: fan-out y delegación de subagentes, un evento, muchos Workers

A menudo, un solo evento necesita disparar trabajo en muchos lugares. El evento charge.refund.failed de Stripe podría necesitar: notificar al agente de soporte, escribir en auditoría, actualizar la puntuación de riesgo del cliente, alertar a operaciones financieras, publicar en Slack. Cinco reacciones, todas independientes, todas de un solo evento.

El patrón de Inngest: suscribir muchas funciones al mismo evento. Sin código de fan-out; solo varios decoradores @inngest_client.create_function con el mismo TriggerEvent. Cada función corre de forma independiente, tiene sus propios reintentos, tiene su propia traza de pasos, falla independientemente de las demás.

@inngest_client.create_function(
fn_id="refund-failed-notify-support",
trigger=inngest.TriggerEvent(event="stripe/charge.refund.failed"),
)
async def notify_support(ctx: inngest.Context) -> dict[str, str]:
# ... runs the customer-support Worker to draft a response ...
return {"status": "drafted"}


@inngest_client.create_function(
fn_id="refund-failed-update-risk-score",
trigger=inngest.TriggerEvent(event="stripe/charge.refund.failed"),
)
async def update_risk_score(ctx: inngest.Context) -> dict[str, float]:
# ... runs the risk-scoring Worker ...
return {"new_risk_score": 0.42}


@inngest_client.create_function(
fn_id="refund-failed-post-slack",
trigger=inngest.TriggerEvent(event="stripe/charge.refund.failed"),
)
async def post_to_slack(ctx: inngest.Context) -> None:
# ... posts a Slack notification ...
return None

Llega un webhook de Stripe. Inngest crea un evento. Tres funciones se activan, cada una en su propia ejecución. Si post_to_slack falla porque Slack está caído, las otras dos no se ven afectadas y se completan normalmente. La ejecución fallida queda en el panel para replay una vez que Slack se recupere. Este es el núcleo de la coordinación multi-Worker, y es el patrón arquitectónico que tu futura capa de gestión (un curso posterior) compondrá a escala.

El otro patrón de fan-out: un padre dispara N hijos. A veces el fan-out es dinámico. Tu cron diario necesita disparar un evento de salud por cada cliente Pro, que podrían ser 500 o 5000 según la semana. La función padre envía N eventos:

from datetime import date

async def fan_out_per_customer_events(
customers: list[str],
) -> int:
events = [
inngest.Event(
name="customer/health_check.requested",
data={"customer_id": cid},
id=f"daily-health-{cid}-{date.today().isoformat()}", # idempotency
)
for cid in customers
]
await inngest_client.send(events=events)
return len(events)

Se envían 5000 eventos en una sola llamada send. Se activan 5000 ejecuciones de función, cada una con su propio customer_id, cada una aislada, cada una reintentable de forma independiente. El control de flujo (Concepto 11) limita cuántas corren concurrentemente para que no derritas tus APIs posteriores. La función cron retorna en segundos; el fan-out corre a la velocidad que permitan las políticas de control de flujo de Inngest.

La delegación de subagentes es un caso especial de fan-out. Dentro de una ejecución de Worker, puedes llamar a await inngest_client.send(...) para delegar subtareas a otros tipos de Worker. El padre no espera a los hijos a menos que use explícitamente step.invoke para correrlos de forma sincrónica y recopilar sus resultados.

Predice. Tienes tres funciones, todas disparadas por customer/email.received: el agente de atención al cliente que redacta una respuesta (15 segundos), un contador de analítica (50 ms) y un "detector VIP" que comprueba si el cliente es de alto valor (200 ms). Cuando llega un correo, ¿cómo es la latencia visible para el usuario en cada una? Tres opciones: (a) las tres suman ~15 segundos; (b) las tres corren en paralelo, la latencia total es ~15 segundos (la más lenta); (c) cada una corre de forma independiente sin ninguna latencia compartida. Confianza 1-5.

La respuesta: (c). Cada función es su propia ejecución, en su propia ranura de proceso. El agente de atención al cliente no bloquea al contador de analítica; el detector VIP no bloquea al agente. Desde fuera, la latencia de cualquier función en particular es solo el tiempo de esa función. Ninguna función espera nunca a una función hermana. Por eso el fan-out escala: los consumidores están aislados. Si el agente falla, el contador de analítica no se ve afectado.

Prueba con IA
Design the fan-out architecture for these three scenarios. For each,
sketch the event names and the functions that subscribe:

A) New customer signs up. Need to: send welcome email, create
Stripe customer, post to Slack #new-customers, write to
audit_log, schedule a 7-day follow-up.

B) Customer support email arrives. Need to: draft a reply (agent),
detect sentiment, check if VIP, update customer's "last contact"
timestamp, attach to the right ticket thread.

C) Daily cron at 09:00 needs to run customer-health-check on
~5,000 Pro customers. Each check takes ~30 seconds. We want
the whole batch to complete by 11:00 (a 2-hour window).

For each, decide: how many event types, how many subscriber
functions, what the idempotency story is, and one specific failure
mode this design protects against.

Parte 2: los reflejos, qué pasa cuando algo se rompe

Los sentidos despiertan al Worker. Los reflejos hacen que el Worker sobreviva a lo que viene después. Un Worker llama a un agente, el agente llama a unas cuantas herramientas, las herramientas llaman a una base de datos y a una API de pagos y a un modelo: varias llamadas externas en un solo turno, cualquiera de las cuales puede fallar. Sin durabilidad, un único fallo transitorio a mitad de turno reinicia todo el flujo desde arriba. Un reflejo es automático: actúa rápido, sin que la mente del agente tenga que decidir. Eso es lo que te da la ejecución duradera. La durabilidad es la propiedad que dice: cuando algo falla a mitad de la ejecución, el trabajo ya completado queda completado, y la ejecución se reanuda desde donde se rompió. Inngest entrega esto con una primitiva (step.run) y una mecánica de memoización por debajo. La Parte 2 explica ambas, además de las variantes basadas en tiempo (step.sleep, step.wait_for_event), la semántica de reintentos y las primitivas step.ai.

Nota de compresión para la primera pasada. Si vas hojeando, los conceptos clave son el 6 (step.run) y el 7 (memoización). Los Conceptos 8 a 10 se construyen sobre ellos. Lee el 6 y el 7 con cuidado; el resto se leerá rápido una vez que tengas esos dos en la cabeza.

Concepto 6: step.run y el modelo de función duradera

Una función Python normal corre una vez, de arriba abajo. Si falla a la mitad, empiezas de nuevo desde arriba. Si hace tres llamadas a una API antes de fallar, el siguiente intento vuelve a hacer esas tres llamadas, y las paga, y posiblemente vuelve a cargarle a alguien.

Una función de Inngest es duradera. Cada operación que quieras tener como punto de control se envuelve en step.run(name, fn, ...). La función sigue corriendo de arriba abajo en cada intento, pero los pasos que ya se completaron devuelven sus salidas almacenadas en lugar de volver a ejecutarse. La función "se pone al día" hasta donde se rompió, y luego continúa hacia delante.

@inngest_client.create_function(
fn_id="customer-support-conversation",
trigger=inngest.TriggerEvent(event="customer/email.received"),
)
async def handle_email(ctx: inngest.Context) -> dict[str, str]:
customer_id = ctx.event.data["customer_id"]

# Step 1: load the customer record (one DB call)
customer = await ctx.step.run(
"load-customer", load_customer_by_id, customer_id,
)

# Step 2: load the conversation thread (one DB call)
thread = await ctx.step.run(
"load-thread", load_thread_for_customer, customer_id,
)

# Step 3: run the OpenAI Agents SDK agent (your worker)
response = await ctx.step.run(
"run-agent",
run_customer_support_agent,
customer=customer,
thread=thread,
email_body=ctx.event.data["body"],
)

# Step 4: write the draft reply to the database
await ctx.step.run(
"save-draft-reply", save_reply,
customer_id=customer_id, text=response.draft,
)

# Step 5: notify the on-call human reviewer via Slack
await ctx.step.run(
"notify-reviewer", post_slack_for_review, response=response,
)

return {"status": "drafted", "reviewer_notified": True}

Cinco pasos. Cada uno tiene su punto de control de forma independiente.

Lo que te compra la durabilidad aquí, en tres escenarios de fallo:

  • Escenario A: el paso del agente lanza un timeout. Sin step.run envolviendo la llamada al agente, el siguiente reintento de esta función vuelve a cargar el cliente, vuelve a cargar el hilo, y vuelve a correr el agente desde cero, pagando tokens de OpenAI dos veces por trabajo que el agente ya hizo parcialmente. Con step.run, las cargas del cliente y del hilo están memoizadas (los pasos 1 y 2 no se vuelven a ejecutar); solo el paso 3 reintenta. Los reintentos automáticos de Inngest manejan los errores transitorios de OpenAI sin que tu código se entere.

  • Escenario B: el proceso de la función muere entre el paso 3 y el paso 4 (salió un despliegue, un nodo se reinició, el contenedor se quedó sin memoria). Sin durabilidad, la respuesta del agente se pierde y el correo del cliente queda sin contestar hasta que alguien lo nota. Con durabilidad, la función se reanuda tras el reinicio: los pasos 1, 2 y 3 devuelven sus salidas almacenadas en milisegundos, el paso 4 corre de verdad, el paso 5 corre de verdad, el cliente recibe la respuesta redactada.

  • Escenario C: Slack devuelve un 503 en el paso 5. Sin step.run, o perderías el trabajo o escribirías a mano la lógica de reintento-y-backoff específicamente para la llamada a Slack. Con step.run, Inngest reintenta el paso 5 con backoff exponencial hasta que Slack se recupere; mientras tanto los pasos 1 a 4 quedan completados y no se vuelven a ejecutar. La respuesta redactada ya está en la base de datos; la notificación es lo único pendiente.

No escribes ningún bucle de reintento, ninguna comprobación de "¿ya hice esto?", ninguna máquina de estados. La máquina de estados es la secuencia de llamadas step.run. Cada paso es un nodo; cada transición es duradera.

La única regla de step.run. La función pasada a step.run debe ser determinista dadas sus entradas: llamarla dos veces con los mismos argumentos debe producir el mismo resultado.

  • Eso es automático para funciones puras.
  • Es automático para llamadas a API idempotentes (la idempotency_key de Stripe, las herramientas de tu propio servidor MCP).
  • Requiere cuidado para cosas como "generar un ID aleatorio" o "llamar a un LLM con temperatura por defecto" (un reintento podría producir una salida distinta del intento original, lo que a veces importa).

Cuando la operación no es determinista, la haces determinista: pasa una semilla, pregenera el valor aleatorio fuera del paso, o acepta que el reintento pueda diferir del original (a menudo está bien para una respuesta de agente).

Comprobación rápida. Verdadero o falso. (a) El cuerpo de la función se vuelve a ejecutar desde arriba en cada reintento, incluidos todos los imports y asignaciones de variables fuera de las llamadas step.run. (b) Si un paso tarda 30 segundos en completarse, y la función falla a los 25 segundos, el reintento continúa ese paso desde el segundo 25. (c) Las salidas de step.run se almacenan en la infraestructura de Inngest, no en tu aplicación.

Respuestas: (a) Verdadero, y por eso mantienes el trabajo dentro de step.run. El código fuera de step.run se vuelve a ejecutar en cada reintento; el código dentro corre una vez por intento y se memoiza al tener éxito. (b) Falso: step.run es la unidad atómica; si un paso se interrumpe, el reintento vuelve a ejecutar todo el paso. Si tu paso es tan largo que no se puede permitir reiniciarlo, lo divides en pasos más pequeños. (c) Verdadero: el almacén de salidas de pasos es parte de Inngest, no de tu base de datos. Por eso puedes hacer replay de ejecuciones incluso después de que el esquema de tu base de datos haya cambiado.

Prueba con IA
With my AI coding assistant connected to the Inngest dev server MCP,
shape a customer-support worker into an Inngest durable function.
Take a Runner.run call that processes a customer email and wrap each
of these inside its own step.run:

1. Load the customer record
2. Load the related conversation thread
3. Run the agent (the OpenAI Agents SDK Runner)
4. Persist the draft reply
5. Notify the on-call reviewer

Use grep_docs to find the current Python SDK syntax. Use
invoke_function to test it with a synthetic email payload. Then
deliberately raise an exception in step 4 and use get_run_status
to confirm steps 1-3 don't re-execute on retry.

Concepto 7: memoización, la mecánica que hay detrás de la reanudabilidad

El Concepto 6 dijo "los pasos que ya se completaron devuelven sus salidas almacenadas en lugar de volver a ejecutarse". Ese mecanismo es la memoización y vale la pena entender la mecánica, porque todas las demás primitivas de Inngest la usan.

Cuando llamas a await ctx.step.run("load-customer", load_customer_by_id, "c-4429"), tres cosas pasan en el primer intento:

  1. Inngest revisa su almacén de memoización: "¿hay un resultado almacenado para el paso load-customer en esta ejecución?" No lo hay.
  2. La función load_customer_by_id("c-4429") corre. Devuelve {"id": "c-4429", "tier": "pro", ...}.
  3. Inngest escribe ese resultado en el almacén de memoización, indexado por (run_id, step_name="load-customer"). Luego devuelve el resultado a tu código.

Si la función falla después del paso 3 e Inngest reintenta, en el segundo intento el cuerpo de la función se vuelve a ejecutar desde arriba. Cuando la ejecución llega a la misma línea, tres cosas distintas pasan:

  1. Inngest revisa su almacén de memoización: "¿hay un resultado almacenado para el paso load-customer en esta ejecución?" Sí, se almacenó en el intento 1.
  2. La función load_customer_by_id("c-4429") no corre. La llamada a la base de datos no ocurre.
  3. Inngest devuelve el resultado almacenado a tu código en milisegundos.

Por eso los reintentos son baratos: el trabajo costoso ya está en caché. Por eso la durabilidad es correcta: el trabajo costoso no ocurre dos veces. Y por eso el que "el cuerpo de la función se vuelva a ejecutar de arriba abajo" está bien a pesar de sonar derrochador: el trabajo dentro de los pasos en realidad no se vuelve a ejecutar; solo el código de orquestación entre pasos lo hace.

Memoización de pasos a través de dos intentos. El intento 1 corre cinco pasos de izquierda a derecha: load-customer, load-thread y run-agent se completan y almacenan su salida, luego save-draft falla y notify nunca se alcanza. El intento 2, el reintento, vuelve a ejecutar la función desde arriba, pero load-customer, load-thread y run-agent se devuelven desde el almacén de memoización a costo cero (el costoso paso run-agent no se vuelve a pagar); save-draft ahora corre de verdad y notify se completa. Un almacén de memoización indexado por (run_id, step_name) guarda las salidas almacenadas. Tres propiedades: los reintentos son baratos, los efectos secundarios ocurren una vez, y los nombres de los pasos son las claves de memoización.

La implicación que sorprende a los nuevos usuarios. El código fuera de step.run corre en cada intento. Si haces esto:

async def handle_email(ctx: inngest.Context) -> dict[str, str]:
# ANTI-PATTERN: this runs on every retry. Don't do this.
expensive_thing: dict = await fetch_expensive_data(ctx.event.data["id"])

await ctx.step.run("do-something", do_something_with, expensive_thing)
return {"status": "done"}

fetch_expensive_data corre en cada reintento. Si cuesta $0.10 por llamada y la función reintenta 5 veces, acabas de gastar $0.50 obteniendo los mismos datos cinco veces. El arreglo es envolver lo costoso en su propio paso:

async def handle_email(ctx: inngest.Context) -> dict[str, str]:
expensive_thing: dict = await ctx.step.run(
"fetch-expensive-data", fetch_expensive_data, ctx.event.data["id"],
)
await ctx.step.run("do-something", do_something_with, expensive_thing)
return {"status": "done"}

Ahora fetch_expensive_data está memoizada; los reintentos no la vuelven a pagar.

El nombre del paso es la clave de memoización. Por eso los nombres de paso deben ser únicos dentro de una función. Si tienes dos llamadas step.run("load-customer", ...) en la misma función, Inngest devolverá la salida almacenada de la primera para ambas llamadas. Eso casi nunca es lo que quieres. Si tienes un bucle que llama a un paso N veces, nómbralos de forma única (step.run(f"load-customer-{i}", ...)) para que cada iteración tenga su propia ranura de memoización.

Predice. Tu función tiene tres pasos. El paso 1 (load-customer) cuesta $0.01 en llamadas a la base de datos y tarda 100 ms. El paso 2 (run-agent) cuesta $0.20 en tokens de OpenAI y tarda 12 segundos. El paso 3 (save-draft) cuesta $0.005 en llamadas a la base de datos y tarda 50 ms. El paso 2 falla el 30 % de las veces por límites de velocidad de OpenAI; Inngest reintenta con backoff. ¿Cuál es la diferencia de costo entre (a) envolver los tres en step.run y (b) envolver solo el paso 2 en step.run? Confianza 1-5.

La respuesta: con (a), un solo reintento te cuesta solo el costo del paso 2 ($0.20). El cliente y el save-draft están memoizados; no se vuelven a ejecutar. Con (b), cada reintento te cuesta los pasos 1 y 3 más el paso 2: $0.215 por reintento. A lo largo de mil correos con una tasa de reintento del 30 %, eso es una diferencia de unos $4.50 en puro desperdicio, más la complejidad operativa de averiguar qué quedó escrito parcialmente cuando el paso 3 corrió dos veces. Envuelve en step.run todo lo que no quieras que se vuelva a ejecutar. No es opcional una vez que entiendes la mecánica.

Prueba con IA
With my AI coding assistant: review the Inngest function we built
in Concept 6's Try-with-AI and identify any code BETWEEN step.run
calls that should be wrapped in its own step but isn't. Common
candidates:

- Computed values (timestamps, IDs, formatting) that we want to be
stable across retries
- Calls to logging or metrics services
- Reads from Redis, environment variables, secret managers

Then propose a refactor that moves each of these into its own step
with a meaningful name. For each, explain whether the side effect
is one you want to happen once (use step.run) or every retry
(leave it outside).

Concepto 8: step.sleep y step.wait_for_event, durabilidad a través del tiempo

Algún trabajo tiene que esperar. Una tubería de correo de bienvenida envía un correo de inmediato, luego espera tres días, luego envía un seguimiento. Una investigación de reembolso necesita esperar a que un humano apruebe. Un flujo de conversión de prueba vigila "el usuario se actualizó a de pago" dentro de 7 días y envía un correo distinto según lo que vea.

En una función Python normal, "esperar tres días" significa mantener un proceso abierto durante tres días. Eso es insostenible: tu proceso se reinicia, tu hosting te factura 72 horas de cómputo inactivo, tu temporizador se pierde. En Inngest, "esperar tres días" es una línea:

from datetime import timedelta

@inngest_client.create_function(
fn_id="trial-welcome-series",
trigger=inngest.TriggerEvent(event="user/trial.started"),
)
async def welcome_series(ctx: inngest.Context) -> dict[str, str]:
user_id = ctx.event.data["user_id"]

await ctx.step.run("send-welcome-email", send_welcome_email, user_id)

# Wait three days. The function gets paged out of memory. Nothing
# is consuming compute. Three days later, Inngest pages it back in
# and resumes execution at the next line.
await ctx.step.sleep("wait-three-days", timedelta(days=3))

await ctx.step.run("send-followup", send_followup_email, user_id)

return {"status": "completed"}

step.sleep es duradero, el sistema nervioso en reposo. La función se suspende; Inngest almacena la hora de reanudación; nada consume cómputo mientras esperas; la función se reanuda a la hora correcta, con todas las salidas de pasos anteriores aún memoizadas. step.sleep (y step.sleep_until) pueden esperar hasta un año en planes de pago, hasta siete días en el plan gratuito Hobby (límites de uso de Inngest). El techo de siete días de Hobby es lo bastante amplio para cada sleep que usa este curso.

El hermano más poderoso es step.wait_for_event. En lugar de esperar un tiempo, espera otro evento. La función se suspende hasta que llega un evento coincidente, o hasta que expira un tiempo de espera que tú definas. Esto es lo que hace de Inngest la expresión más limpia de HITL (Concepto 15) y de los patrones de coordinación entre agentes:

@inngest_client.create_function(
fn_id="refund-with-approval",
trigger=inngest.TriggerEvent(event="customer/refund.requested"),
)
async def refund_with_approval(ctx: inngest.Context) -> dict[str, str]:
request = ctx.event.data
request_id = request["request_id"]

# If amount is over $100, require approval before issuing
if request["amount_cents"] >= 10_000:
# Notify a human via Slack/email/whatever
await ctx.step.run("notify-approver", notify_human_approver, request)

# Wait for an approval event. Up to 24 hours; expires otherwise.
approval = await ctx.step.wait_for_event(
"wait-for-approval",
event="refund/approval.decided",
timeout=timedelta(hours=24),
if_exp=f"async.data.request_id == '{request_id}'",
)

if approval is None or not approval.data.get("approved"):
return {"status": "rejected_or_timeout"}

# Either it was under $100, or it was approved
refund = await ctx.step.run(
"issue-stripe-refund", call_stripe_refund_api, request,
)
return {"status": "issued", "refund_id": refund["id"]}

Lo que está pasando:

  • La función llega a wait_for_event. Se suspende. Cero cómputo consumido.
  • Un humano mira la notificación de Slack, hace clic en "Approve" en tu interfaz de administración, tu interfaz llama a inngest_client.send(events=[Event(name="refund/approval.decided", data={"request_id": "...", "approved": True})]).
  • Inngest hace coincidir el evento con la función en espera (el if_exp asegura que solo los eventos de este request_id coincidan) y reanuda la función con el evento como valor de retorno de approval.
  • La función continúa hasta el paso de reembolso. El reembolso de Stripe ocurre después de que el humano aprobó.

step.sleep y step.wait_for_event son tiempos de espera que no pagas. La función se ve sincrónica en tu código ("espera tres días, luego envía el correo"), pero la semántica en tiempo de ejecución es asíncrona y duradera. Esta es una de las dos cosas por las que Inngest es famoso (los reintentos duraderos son la otra). Sin ella, la alternativa es una cola más una máquina de estados más una base de datos más un sondeo, y escribirías mil líneas en vez de tres.

Comprobación rápida. Tres afirmaciones. Marca cada una como Verdadera o Falsa. (a) Si step.sleep se configura para 30 días y tu servicio se redespliega cinco veces en esos 30 días, el sleep continúa sin interrupción en un plan de pago. (b) Si step.wait_for_event agota el tiempo de espera, la función lanza una excepción. (c) Dos llamadas step.wait_for_event en la misma función pueden esperar el mismo evento simultáneamente.

Respuestas: (a) Verdadero en un plan de pago: los sleeps se almacenan en la infraestructura de Inngest, no en la memoria de tu servicio, así que los redespliegues no los pierden. Nota el techo del nivel: un sleep de 30 días está bien en un plan de pago pero excede el tope de siete días del plan gratuito Hobby. (b) Falso: al agotar el tiempo de espera, wait_for_event devuelve None. Tu código lo comprueba y decide qué hacer (rechazo, escalada, aprobación por defecto, la política que sea). (c) Verdadero, pero sospechoso: ambas se activarán cuando llegue un evento coincidente. Si las dos llamadas wait_for_event tienen filtros if_exp distintos, está bien. Si son idénticas, probablemente estés viendo una oportunidad de refactorización.

Prueba con IA
Build a delayed-investigation flow with my AI coding assistant.
Specification:

1. Triggered by event 'customer/refund.failed'.
2. Immediately notify the on-call human via Slack with the refund
details and a "Investigate" button.
3. Wait for the human to click the button (which fires
'customer/refund.investigation_started') for up to 4 hours.
4. If the click arrives in time: run the agent to draft an
investigation summary.
5. If 4 hours pass without a click: escalate to a senior reviewer
by firing 'customer/refund.escalated'.

Use the dev-server MCP's send_event tool to simulate the
human-click event during testing. Use get_run_status to inspect
how the suspended function shows up in the dashboard. Before
writing, use list_docs to scan the Inngest documentation tree
for the right page on wait_for_event semantics, then
read_doc on the page you find to get the exact syntax for
the if_exp filter expression.

Concepto 9: reintentos, manejo de errores, dead-letter

Este es el reflejo de cerca. Por defecto, Inngest reintenta los pasos fallidos. Los valores por defecto son sensatos: ~4 reintentos con backoff exponencial, que van de unos segundos a unos minutos entre intentos. Después de que el reintento final falla, la ejecución entra en estado failed y se queda ahí para inspección y (opcionalmente) replay. Puedes ajustar esto por función: retries=10, retries=0 (no reintentar en absoluto), tipos de excepción específicos que no deberían reintentarse.

@inngest_client.create_function(
fn_id="charge-customer",
trigger=inngest.TriggerEvent(event="order/checkout.completed"),
retries=2, # only retry twice; this involves Stripe; don't keep hammering
)
async def charge_customer(ctx: inngest.Context) -> dict[str, str]:
try:
charge = await ctx.step.run(
"call-stripe", call_stripe_charge, ctx.event.data,
)
return {"status": "charged", "charge_id": charge["id"]}
except StripeCardDeclinedError as e:
# A declined card is not a transient failure. Don't retry.
# Mark the order as failed in our database and emit an event
# for the dunning flow.
await ctx.step.run(
"mark-failed", mark_order_failed,
ctx.event.data["order_id"], reason=str(e),
)
await ctx.step.run(
"emit-dunning-event", emit_dunning, ctx.event.data["order_id"],
)
return {"status": "card_declined"}

Tres patrones importan.

Patrón 1: fallos transitorios vs permanentes. Inngest reintenta todo por defecto, pero algunos errores no son transitorios. Un error de tarjeta rechazada de Stripe se volverá a rechazar en el reintento. Un 401-no-autorizado de tu API posterior no se convertirá en un 200 solo porque esperes. Tu función debería capturarlos específicamente y manejarlos: escribe en tu base de datos, emite un evento posterior, retorna limpiamente, para que no desperdicien presupuesto de reintentos en intentos sin esperanza. La NonRetriableError de Inngest le dice explícitamente a Inngest que omita los reintentos para una excepción lanzada.

Patrón 2: errores a nivel de paso vs a nivel de función. Un paso que lanza se reintenta. Después de agotar los reintentos a nivel de paso, la función falla. A veces quieres que una función sobreviva a un paso que falla: registrar el fallo, marcar el trabajo como "parcial", continuar. Envuelve el step.run en try/except. El paso aún recibe sus reintentos; si todos los reintentos fallan, la excepción se propaga a tu bloque catch, donde puedes decidir qué hacer.

Patrón 3: dead-letter y replay. Cuando una función falla por completo, no desaparece. Entra en la vista de "failed runs" del panel de Inngest, con la traza completa, todas las salidas de pasos, la excepción y un botón Replay. Después de enviar un arreglo, puedes hacer replay de las ejecuciones fallidas: cada una se vuelve a ejecutar desde arriba sobre el código corregido (una ejecución nueva, no una reanudación que preserva la memoización, esa distinción es el Concepto 14). Este es el patrón de "cola de mensajes muertos" de las colas tradicionales, salvo que no escribes el manejador de dead-letter. Solo corriges el error y haces replay, manteniendo idempotentes los pasos con efectos secundarios para que una nueva ejecución no actúe dos veces.

Predice. Tu función llama a Stripe en el paso 2 y a tu servicio de datos de clientes en el paso 4. Stripe devuelve 503 (servicio no disponible, transitorio) en el primer intento del paso 2. El paso 2 reintenta 4 veces con backoff exponencial (~1 s, ~2 s, ~5 s, ~12 s); en el 4.º reintento, Stripe vuelve, el cargo tiene éxito. Ahora corre el paso 4, y el servicio de datos está caído con un 500. ¿Inngest reintenta toda la función, o solo el paso 4? ¿Cuántas veces? Confianza 1-5.

La respuesta: solo el paso 4, y obtiene su propio presupuesto de reintentos. Los pasos no comparten reintentos. Los cuatro reintentos del paso 2 son independientes de los del paso 4. Inngest reintentará el paso 4 (por defecto ~4 veces) y si el servidor MCP vuelve, el paso 4 se completa, y la función tiene éxito. El cargo de Stripe del paso 2 no se vuelve a emitir, porque la salida del paso 2 se memoizó tras su reintento exitoso. Al cliente se le cobra exactamente una vez aunque la función haya pasado 20 segundos a lo largo de los reintentos.

Prueba con IA
With my AI coding assistant: extend the customer-support Worker
function from Concept 6 with explicit retry and failure handling.
Specification:

1. The OpenAI Agents SDK call should retry 3 times on transient
failures (rate limit, timeout), but NOT retry on a content-policy
refusal from the model.
2. The Slack notification should retry up to 10 times (Slack is
often flaky; don't lose the notification).
3. The Postgres write should retry once; if it fails again, log the
failure and continue (don't fail the whole function over a
transient DB blip).

For each step, decide what's transient vs permanent and structure
the try/except accordingly. Use grep_docs to find the Python SDK's
NonRetriableError equivalent.

Concepto 10: step.run para llamadas de IA en Python (step.ai.wrap es solo de TypeScript)

Los Conceptos 6 a 9 funcionan para cualquier código con efectos secundarios: escrituras en base de datos, llamadas a API, escrituras de archivos, invocaciones de agentes. Inngest también incluye primitivas de paso específicas para IA que manejan los patrones a los que son propensas las llamadas a LLM: reintentos por límite de velocidad, observabilidad de los prompts y las respuestas, y (opcionalmente) un proxy de inferencia que reduce los costos de cómputo serverless.

Nota importante de Python frente a TypeScript de entrada. El módulo step.ai de Inngest tiene dos métodos, y tienen soporte de lenguaje distinto. step.ai.infer() está disponible tanto en TypeScript como en Python (SDK de Python v0.5+): descarga la inferencia a la infraestructura de Inngest y traza la llamada. step.ai.wrap() es solo de TypeScript: hoy no hay equivalente en Python. Para proyectos de Python (como el Worker de este curso), el patrón correcto para envolver una llamada del OpenAI Agents SDK es ctx.step.run(...), que ya te da durabilidad completa, reintentos y observabilidad de las entradas y salidas del paso envuelto. Lo único que no obtienes es la telemetría específica de LLM de prompt/respuesta que agrega el step.ai.wrap de TypeScript. (Verificado contra la documentación de inferencia de IA a mayo de 2026.)

step.run para llamadas a OpenAI en Python (el patrón recomendado). Tu función hace la llamada a OpenAI dentro de ctx.step.run("name", fn, ...). Inngest traza las entradas y salidas del paso (los argumentos que pasaste y lo que se devolvió), reintenta ante fallos transitorios y memoiza el resultado para que los reintentos de pasos posteriores no vuelvan a pagar el costo de OpenAI. El prompt y la respuesta se registran como la entrada/salida del paso en el panel:

from openai import AsyncOpenAI

oai = AsyncOpenAI()


async def call_openai_summary(thread_text: str) -> str:
"""A normal async function. Inngest doesn't care that this is an LLM call."""
response = await oai.chat.completions.create(
model="gpt-5",
messages=[
{"role": "system", "content": "Summarize this support thread in 3 sentences."},
{"role": "user", "content": thread_text},
],
)
return response.choices[0].message.content


@inngest_client.create_function(
fn_id="summarize-customer-thread",
trigger=inngest.TriggerEvent(event="customer/thread.summary_requested"),
)
async def summarize_thread(ctx: inngest.Context) -> dict[str, str]:
thread: list = await ctx.step.run(
"load-thread", load_thread, ctx.event.data["thread_id"],
)

# The OpenAI call is wrapped in step.run. Inngest sees this as a step:
# the inputs (formatted thread text) are recorded, the output (summary
# string) is recorded, the call is memoized on success, and retries are
# automatic on transient failures.
summary: str = await ctx.step.run(
"openai-summary", call_openai_summary, format_thread(thread),
)

return {"summary": summary}

En el panel, esta ejecución muestra la traza de pasos de la función (load-thread seguido de openai-summary) con las entradas y salidas de cada paso. Si OpenAI devolvió un 429 (límite de velocidad), Inngest reintenta openai-summary con backoff automáticamente: la misma semántica de memoización del Concepto 7, así que los reintentos no vuelven a facturar el paso load-thread anterior. Lo que no obtienes (comparado con step.ai.wrap de TypeScript): telemetría automática específica de LLM como conteos de tokens, nombre del modelo y trazas específicas del proveedor desglosadas en la vista de IA del panel. Para la mayoría de las cargas de producción de Python, la traza de pasos estándar más tu propia telemetría del cliente de OpenAI (por ejemplo, el tracing del OpenAI Agents SDK) cubre esta brecha.

Las trazas de pasos y los datos de clientes

Como step.run registra las entradas y salidas de cada paso en el almacén de observabilidad de Inngest, el contenido que pasas a través de un paso se almacena y es visible en el panel. Si tu prompt incluye PII (nombres, correos, direcciones), secretos (claves de API, tokens internos), datos contractuales o financieros, o contenido regulado (datos bajo HIPAA, datos cubiertos por el RGPD, PCI), no pases el contenido en crudo al cuerpo del paso. Redacta, aplica hash, resume o pasa una referencia (un customer_id y un ticket_id, no el texto completo del ticket) y vuelve a cargar el contenido sensible dentro del cuerpo del paso desde tu almacén autoritativo, donde la retención y los controles de acceso son tuyos de configurar. La misma disciplina aplica al propio tracing del OpenAI Agents SDK si lo activas. Trata las trazas de pasos como tratarías cualquier log de producción: útiles por defecto, reguladas por política.

step.ai.infer: una herramienta de nicho para reducir el costo serverless (con soporte en Python). Rara vez recurrirás a esto; step.run es el valor por defecto para cada llamada de IA en este curso. step.ai.infer existe para una situación específica: en lugar de llamar a OpenAI desde el proceso de tu función, le pides a la infraestructura de Inngest que haga la llamada, así que mientras la solicitud está en vuelo tu proceso de función puede liberarse. En plataformas serverless (Vercel, Cloudflare Workers, AWS Lambda) que facturan por tiempo en vuelo, esto ahorra costo de cómputo durante la espera. Para inferencias de larga duración (Deep Research, lotes grandes de embeddings) los ahorros son reales. Para llamadas de menos de un segundo, agrega latencia sin mucho beneficio.

Si alguna vez recurres a ello, saca la firma exacta de la documentación de inferencia de IA para tu versión instalada: vive en el namespace experimental inngest.experimental.ai y no se ejercitó en la construcción de este curso.

Comprobación rápida. Verdadero o falso. (a) En Python, ctx.step.run("name", call_openai, ...) hace que la llamada a OpenAI sea duradera, reintentada ante fallos transitorios y memoizada al tener éxito. (b) step.ai.infer es un requisito imprescindible para usar Inngest con el OpenAI Agents SDK en Python. (c) Reemplazar step.run por step.ai.infer para una sola llamada a OpenAI siempre haría que la función fuera más barata de ejecutar.

Respuestas: (a) Verdadero: este es el patrón recomendado de Python. La llamada a OpenAI va dentro del cuerpo del paso; Inngest trata todo el paso como la unidad de trabajo. (b) Falso: step.run basta para la mayoría de los casos. step.ai.infer es una optimización para el costo de cómputo serverless, no un requisito. La integración del OpenAI Agents SDK en el ejemplo trabajado usa step.run simple. (c) Falso: step.ai.infer ahorra dinero solo cuando (i) estás en una plataforma serverless que factura por tiempo en vuelo Y (ii) la llamada es lo bastante larga como para que el ahorro por descarga de la solicitud domine sobre la sobrecarga de orquestación añadida. Para llamadas de menos de un segundo en servidores siempre activos, gana step.run simple.

Prueba con IA
With my AI coding assistant: take a customer-support agent
invocation and produce TWO versions of the Inngest function that
calls it:

Version A: Wrap the Runner.run call in step.run (the recommended
Python pattern: durable, retried on transient failures, memoized;
you get the standard step trace).

Version B: For comparison, write a SEPARATE small Inngest function
that calls a single OpenAI completion via step.ai.infer (the
Python-supported step.ai primitive that offloads inference to
Inngest's infrastructure to save serverless compute cost).

For each version, explain (a) what the dashboard trace shows for a
successful run, (b) what happens when the OpenAI call hits a 429
rate limit, and (c) on which kind of deployment (always-on server
vs serverless) Version B's offload saves real money.

Parte 3: equilibrio y recuperación, escala de producción

El equilibrio es la tercera capa: mantiene al Worker en buen estado bajo carga, igual que tu cuerpo se mantiene firme cuando lo exiges. La concurrencia impide que el Worker derrita los sistemas posteriores. El throttling te mantiene alejado de los muros de límite de velocidad. La prioridad y la equidad evitan que un cliente parlanchín deje a todos sin capacidad. El procesamiento por lotes convierte "10 000 eventos a medianoche" en "100 ejecuciones de función manejables". El replay convierte "el error de ayer nos costó 200 interacciones fallidas" en "lo arreglamos; 200 conversaciones reanudadas". Las puertas de aprobación humana suspenden al agente hasta que un humano apruebe. Los cinco conceptos de la Parte 3 te dan las políticas de producción que convierten a un Worker que funciona en uno que puedes poner frente a clientes que pagan.

Concepto 11: concurrencia y throttling

La concurrencia es el número máximo de ejecuciones de una función que pueden correr simultáneamente. El throttling es el número máximo de ejecuciones que pueden iniciarse por unidad de tiempo. Ambos se configuran por función con una línea cada uno. Ambos son la brecha de producción más común cuando los equipos pasan de prototipo a escala.

from datetime import timedelta

@inngest_client.create_function(
fn_id="customer-support-conversation",
trigger=inngest.TriggerEvent(event="customer/email.received"),
concurrency=[inngest.Concurrency(limit=10)],
throttle=inngest.Throttle(limit=100, period=timedelta(minutes=1)),
)
async def handle_email(ctx: inngest.Context) -> dict[str, str]:
...

concurrency=10 dice: como máximo 10 de estas funciones están corriendo en cualquier momento. El 11.º evento espera en cola hasta que una de las 10 termine. throttle=100/minute dice: como máximo 100 ejecuciones nuevas inician por minuto. El 101.º evento espera aunque haya margen de concurrencia.

Por qué ambos importan en la práctica. La concurrencia protege a los sistemas posteriores: si tu Worker de atención al cliente habla con OpenAI y Postgres, tener 1000 ejecuciones concurrentes significa 1000 llamadas simultáneas a OpenAI y 1000 conexiones simultáneas a Postgres. Agotarás tu límite de velocidad de OpenAI, agotarás tu pool de conexiones, o ambos. El throttle protege contra ráfagas: si 500 correos de clientes llegan a las 9:00 en punto, no quieres que 500 funciones inicien en el mismo segundo; el throttle suaviza la tasa de inicio.

Concurrencia por clave. Un solo límite de concurrency aplica a la función de forma global. Un patrón más interesante es la concurrencia por clave: limitar según alguna propiedad del evento.

@inngest_client.create_function(
fn_id="customer-support-conversation",
trigger=inngest.TriggerEvent(event="customer/email.received"),
concurrency=[
inngest.Concurrency(limit=10), # global cap
inngest.Concurrency(limit=2, key="event.data.customer_id"), # per-customer cap
],
)
async def handle_email(ctx: inngest.Context) -> dict[str, str]:
...

Esto dice: como máximo 10 funciones corriendo de forma global, Y como máximo 2 por cliente a la vez. Si un solo cliente envía 100 correos en un minuto, solo 2 de sus correos se procesan simultáneamente; los otros 98 esperan en cola detrás. Mientras tanto, los correos de otros clientes fluyen normalmente; no quedan bloqueados por el cliente parlanchín. Esto es equidad multiinquilino en dos líneas de código. El Concepto 12 desarrolla más el patrón.

Comprobación rápida. Tres afirmaciones, Verdadero o Falso. (a) Si pones concurrency=10 y llegan 1000 eventos a la vez, 990 de ellos se descartan. (b) Tanto el throttling como los límites de concurrencia reducen el rendimiento total. (c) La concurrencia por clave requiere una clave que sea determinista a partir de los datos del evento.

Respuestas: (a) Falso: los eventos no se descartan; se encolan. La cola de Inngest es duradera; los 990 eventos esperan hasta que se abran ranuras de concurrencia. (b) Falso. El throttling limita la tasa de inicio; la concurrencia limita las ejecuciones en vuelo. Ninguno descarta trabajo; ambos dan forma a cuándo se ejecuta el trabajo. El rendimiento a lo largo de una ventana larga no cambia si tu carga promedio está por debajo de los límites. El rendimiento durante un pico sí toma forma: las ráfagas las absorbe la cola. (c) Verdadero: la expresión de la clave se evalúa sobre los datos del evento; tiene que producir una cadena estable para el mismo ámbito lógico (customer_id está bien; current_timestamp no).

Prueba con IA
With my AI coding assistant: design the concurrency and throttling
policy for the customer-support Worker. Constraints:

- OpenAI rate limit: 30 requests per minute, hard cap.
- Postgres connection pool: 20 max connections (the Worker takes 1 per run).
- Some customers send bursts of 30+ emails in a minute (an angry
customer); these shouldn't starve other customers.
- We expect ~1,000 emails per day, with peaks around 9am and 2pm.

Propose:
1. A global concurrency value
2. A per-customer concurrency value
3. A throttle (limit and period)

For each, explain what production failure it protects against and
what the cost is (in queue latency at peak).

Concepto 12: prioridad y equidad, escalado multiinquilino

Los límites de concurrencia funcionan. La concurrencia por clave agrega una equidad básica. Los sistemas multiinquilino de grado de producción necesitan más: prioridades (los clientes Enterprise no deberían esperar detrás de aficionados por el mismo cómputo) y programación de reparto justo (ningún inquilino puede monopolizar el sistema ni siquiera dentro de su tope de concurrencia).

Prioridad. Inngest evalúa una expresión de prioridad en cada evento; las ejecuciones con mayor prioridad se saltan la cola por delante de las de menor prioridad.

@inngest_client.create_function(
fn_id="customer-support-conversation",
trigger=inngest.TriggerEvent(event="customer/email.received"),
concurrency=[inngest.Concurrency(limit=10)],
priority=inngest.Priority(
# Enterprise tier = high priority; Pro = 0; Free = low priority
run="100 - (event.data.customer_tier_priority * 100)",
),
)
async def handle_email(ctx: inngest.Context) -> dict[str, str]:
...

Cuando la cola de concurrencia tiene 50 ejecuciones esperando, las ejecuciones de los clientes Enterprise van primero, luego Pro, luego Free. Dentro del mismo nivel, aplica el orden FIFO. La prioridad no anula los límites de concurrencia ni de throttle; solo decide cuál de las ejecuciones en espera obtiene la siguiente ranura libre. Un cliente Enterprise igual espera a que se abra una ranura; solo que obtiene la siguiente.

Programación de reparto justo. Cuando tienes cientos de inquilinos compitiendo por el mismo pool global de concurrencia, FIFO más prioridad no basta. Un solo inquilino que envíe una ráfaga aún puede ocupar la mayoría de las ranuras durante minutos. La programación de reparto justo, implementada con el parámetro key en concurrency con un dimensionamiento bien pensado, le da a cada inquilino una porción garantizada:

concurrency=[
inngest.Concurrency(limit=50), # global pool
inngest.Concurrency(limit=3, key="event.data.tenant_id"), # max 3 per tenant
],

Con esto: 50 ranuras totales, ningún inquilino toma más de 3. Si hay 20 inquilinos activos, eso es como máximo 60 ranuras solicitadas pero solo 50 disponibles. El reparto justo los va rotando, cada inquilino obtiene algo de cuota, nadie queda excluido.

Predice. Tienes una función de atención al cliente con concurrency=10 y concurrency=2 por cliente. También tienes prioridad configurada: Enterprise = alta, Free = baja. A las 9:00, la cola tiene: 5 eventos del Cliente A (Free), 5 eventos del Cliente B (Enterprise) y 10 eventos de un solo Cliente C nuevo (Free, que acaba de comprar su primer plan). ¿En qué orden se ejecutan? Confianza 1-5.

La respuesta: es una decisión de varios niveles. Primero, el tope por cliente de 2 significa que como máximo 2 de los eventos de cada cliente son elegibles para correr a la vez. Así que el grupo de candidatos es: 2 de A, 2 de B, 2 de C: seis ejecuciones elegibles de inmediato. Segundo, la prioridad decide cuáles de esas seis llenan las primeras ranuras: las dos de B corren primero (Enterprise), luego las dos de A y las dos de C (Free, FIFO). Así que en t=0: corren 2 de B, luego inician 2 de A, luego inician 2 de C. Total: 6 activas. A medida que cada una termina, el siguiente evento en cola de su cliente se vuelve elegible y la siguiente ranura se llena por prioridad. Este es el tipo de política que es una función en Inngest y un programador de mil líneas en tu propio código.

Prueba con IA
With my AI coding assistant: extend the customer-support Worker
configuration with a priority and fair-share scheme. Requirements:

1. Three customer tiers: Enterprise, Pro, Free.
2. Enterprise customers should never wait more than 5 seconds at
peak load.
3. Free tier customers should get fair access: no Free customer
should be starved for more than 60 seconds, even when the
global queue is full.
4. A single noisy customer (regardless of tier) should not occupy
more than 3 slots.

Write the concurrency + priority configuration. For each line of
config, explain which requirement it satisfies.

Concepto 13: procesamiento por lotes, trabajo masivo rentable

Algún trabajo es naturalmente por lotes. No resumes cada una de 10 000 conversaciones de clientes de forma independiente; llamas al LLM con un lote de 50 a la vez. No escribes 10 000 filas de auditoría una a la vez; las haces con COPY. El disparador por lotes de Inngest te deja acumular eventos e invocar una sola función con el lote como entrada.

@inngest_client.create_function(
fn_id="batch-embed-tickets",
trigger=inngest.TriggerEvent(event="ticket/resolved"),
batch_events=inngest.Batch(
max_size=50, # invoke when 50 events accumulated, OR
timeout=timedelta(seconds=30), # invoke when 30 seconds pass, whichever first
),
)
async def batch_embed_resolved_tickets(ctx: inngest.Context) -> dict[str, int]:
# ctx.events (plural) instead of ctx.event
ticket_ids = [e.data["ticket_id"] for e in ctx.events]

tickets = await ctx.step.run(
"load-tickets", load_tickets_by_ids, ticket_ids,
)

# One embedding call for 50 tickets, not 50 calls for 1 ticket each
embeddings = await ctx.step.run(
"embed-batch", embed_texts_batch,
[t["text"] for t in tickets],
)

await ctx.step.run(
"store-embeddings", store_embeddings_batch,
ticket_ids, embeddings,
)

return {"batched": len(ctx.events)}

Lo que cambia: ctx.events es una lista, no un solo evento. La función corre una vez por lote en lugar de una vez por evento. La API de embeddings de OpenAI se llama con un lote de 50 textos en lugar de 50 llamadas de un texto cada una, lo que es muchísimo más barato (pagas por token, pero la sobrecarga por solicitud desaparece) y más rápido (un viaje de ida y vuelta a la API en lugar de 50).

El procesamiento por lotes es la herramienta correcta cuando el trabajo es naturalmente agrupable (embeddings, escrituras masivas en base de datos, correos masivos) y puedes tolerar hasta la latencia de tu tiempo de espera antes de que el trabajo ocurra. Es la herramienta equivocada cuando cada evento requiere respuesta interactiva o cuando el orden entre eventos importa de formas impredecibles.

Comprobación rápida. Verdadero o falso. (a) Las funciones por lotes siguen recibiendo reintentos y memoización; el lote como un todo se memoiza de forma duradera. (b) Si el tiempo de espera del lote expira con solo 3 eventos acumulados, la función no correrá hasta que lleguen los siguientes 47. (c) Puedes combinar batch_events con concurrency para limitar cuántos lotes corren en paralelo.

Respuestas: (a) Verdadero: el lote es la unidad de trabajo; los reintentos reproducen todo el lote con todos sus eventos aún en alcance. (b) Falso: ese es el punto del tiempo de espera. Tras 30 segundos la función corre con lo que se haya acumulado, aunque sea 1 evento. (c) Verdadero: este es el patrón de producción. El lote más la concurrencia juntos limitan bien tu carga posterior.

Prueba con IA
With my AI coding assistant: write a batched Inngest function that
embeds resolved support tickets, converting a per-ticket event
handler into one batched call.

Triggers: 'ticket/resolved' event, batched at 50 events or 30 seconds.

The function should:
1. Load the ticket bodies in one query
2. Call OpenAI embeddings API with a 50-text batch (faster + cheaper)
3. Store the embeddings
4. Emit a 'ticket/embedded' event per ticket for downstream consumers

Use grep_docs to find the OpenAI batch-embedding pattern.

Concepto 14: replay y cancelación masiva, recuperación de producción

A veces todo sale mal a la vez. Enviaste un error; mil ejecuciones fallaron en las últimas seis horas. O tu API posterior estuvo caída 30 minutos; todo lo que intentó llamarla en esa ventana murió. O descubriste un error de lógica y quieres rehacer el trabajo de un día tras corregirlo.

Primero, la distinción que confunde a todos. Inngest te da dos formas en que un paso fallido puede volver a correr, y se comportan de forma distinta:

  • Reintento automático (dentro de la misma ejecución). Cuando un paso lanza, Inngest reintenta la función con backoff, volviendo a entrar desde arriba. Los pasos completados se devuelven desde la memoización y no se vuelven a ejecutar; solo el paso que falla corre de nuevo. Esta es la reanudación que preserva la memoización, la que observaste en la Victoria rápida, y la que hace verdadera la propiedad de "los $0.20 gastados en el paso 3 no se vuelven a gastar". Es automática y ocurre dentro de la ejecución original.
  • Replay / Rerun (el botón del panel, a través de muchas ejecuciones). Esto inicia una ejecución nueva desde arriba con tu código actualmente desplegado, y cada paso se vuelve a ejecutar desde cero (un rerun obtiene un nuevo id de ejecución y vuelve a correr el primer paso, no una reanudación de la anterior). Así que en la práctica la memoización de la ejecución antigua no te salva aquí. Es para recuperación de incidentes, no para saltar trabajo completado.

Mantener estos diferenciados es el concepto entero. La recompensa de la memoización vive en el reintento automático; el Replay es un comienzo en limpio.

Dos primitivas de recuperación opuestas. El replay dice "este trabajo falló, quiero que vuelva a correr sobre el código corregido". La cancelación masiva dice "este trabajo se encoló pero ya no quiero que ocurra". La misma superficie del panel, intención opuesta. La mayoría de los equipos necesitan ambas dentro de sus primeros tres meses de tráfico real.

El replay es la primitiva de recuperación. Las ejecuciones fallidas persisten con su historial completo de pasos, el evento de entrada y la excepción del paso fallido. Desde el panel abres la vista de Functions, filtras a una función que tiene ejecuciones fallidas, seleccionas una ventana de tiempo y un patrón de fallo (cualquier mensaje de error específico o simplemente "todos los fallos"), y haces clic en Replay. Inngest programa cada una como una ejecución nueva desde arriba sobre el código que esté desplegado ahora.

Tres cosas que entender sobre el replay.

  • El replay usa tu código actualmente desplegado. Si desplegaste un arreglo entre el momento en que las ejecuciones fallaron y el momento en que las reproduces, las ejecuciones reproducidas usan el código nuevo. Ese es el punto: tomar una población de ejecuciones que murieron por un error, enviar el arreglo, y volver a correrlas todas sin intervención.
  • El replay vuelve a ejecutar cada paso; no reutiliza la memoización de la ejecución antigua. Una ejecución reproducida es una ejecución nueva, así que cada paso corre de nuevo desde cero sobre el código corregido. En cuanto a costo, planifica para el costo de la función completa por ejecución reproducida, no solo del paso fallido. Lo que evita que un replay emita un segundo efecto secundario en el mundo real (un reembolso duplicado, un correo duplicado) no es la memoización, es una clave de idempotencia sobre ese efecto secundario (Concepto 4): derivas una clave estable a partir de la solicitud (para un reembolso, algo como (order_id, request_id)) y el proveedor trata una repetición como un no-op. El Worker mínimo de este curso omite esa clave por brevedad, su reembolso coincide por cliente y escribe sin condiciones, así que una versión de producción agregaría una antes de que se mueva dinero real. La memoización protege dentro de una ejecución; la clave de idempotencia protege a través de reruns.
  • El replay es opcional (opt-in). Las ejecuciones fallidas quedan en el panel hasta que actúas sobre ellas. No reintentan para siempre; no desaparecen. Te esperan.

La cancelación masiva es lo inverso. A veces tienes miles de ejecuciones en cola o durmiendo que ya no quieres: una campaña se canceló, un cliente se fue y ya no quieres enviarle correos de seguimiento, una función se revirtió. Desde el panel seleccionas una función y una ventana de tiempo o filtro de evento, y haces clic en Cancel. Las ejecuciones coincidentes terminan limpiamente: sus llamadas step.sleep y step.wait_for_event no se reanudan, las ejecuciones en cola no inician, las ejecuciones en vuelo comprueban la cancelación y salen en el siguiente límite de paso. La cancelación respeta el límite de paso; un step.run en vuelo termina el paso en el que está antes de terminar, así que no obtienes cargos de Stripe a medio completar ni escrituras de base de datos rotas.

Replay vs cancelación como decisión. Cuando algo ha salido mal con una población de ejecuciones, hazte una pregunta: ¿quiero que este trabajo tenga éxito o que no ocurra? Si el trabajo debe tener éxito (recuperación tras arreglo de error), replay. Si el trabajo no debe ocurrir (campaña cancelada, cliente que se fue, función revertida), cancela. Si no estás seguro (por ejemplo, las ejecuciones fallidas incluyen algunas que quieres recuperar y otras que no deberían haberse disparado en primer lugar), filtra tu consulta del panel de forma más estrecha para que cada subconjunto reciba el tratamiento correcto.

Tres patrones que esto habilita en la práctica:

  • La recuperación de "enviamos un error". Encuentra las ejecuciones fallidas en la ventana de tiempo del mal despliegue, corrige el error, envía el arreglo, reproduce los fallos. La experiencia del cliente: su correo no recibió respuesta durante una hora pero sí la recibió al final, sin que tú escribieras código de recuperación.
  • La reversión de "campaña cancelada". Una serie de bienvenida que dispara tres correos de seguimiento a lo largo de 14 días; el cliente se va el día 4. No quieres enviar los seguimientos del día 7 y el día 14. Cancela en masa las ejecuciones wait-for-event y sleep coincidentes.
  • El replay de "migración de esquema". Cambiaste cómo el agente formatea los resúmenes; quieres que los tickets de ayer se vuelvan a resumir con el formato nuevo. Encuentra esas ejecuciones (exitosas o no) y reprodúcelas; como un replay es una ejecución nueva desde arriba, el agente vuelve a correr cada paso sobre el código nuevo, que es exactamente lo que quieres aquí. Mantén idempotentes tus pasos con efectos secundarios para que volver a correrlos no cobre ni envíe dos veces.

El MCP del servidor de desarrollo hace accesible la recuperación sin salir de tu agente de programación. Durante el desarrollo puedes pedirle a la IA que use get_run_status para inspeccionar una ejecución fallida, y luego recuperar el trabajo volviendo a disparar el evento sobre el código corregido (dale un id de evento nuevo, ya que volver a disparar con el mismo id se deduplica a un no-op por la semántica de idempotencia del Concepto 4). El botón Rerun del panel es la ruta equivalente de un clic. De cualquier forma obtienes una ejecución nueva sobre el código actual, no una reanudación que preserva la memoización.

Comprobación rápida. Verdadero o falso. (a) Un Replay del panel vuelve a correr el trabajo sobre el código nuevo desplegado. (b) Un Replay del panel devuelve los pasos exitosos de la ejecución original desde la memoización y solo vuelve a correr el fallido. (c) El reintento automático dentro de una ejecución fallida devuelve los pasos completados desde la memoización y vuelve a correr solo el paso que falla. (d) Cancelar en masa una función que está en vuelo abortará a mitad de paso el step.run que se está ejecutando para terminar más rápido.

Respuestas: (a) Verdadero: un replay es una ejecución nueva desde arriba sobre lo que esté desplegado ahora, por eso es la herramienta para la recuperación tras arreglo de error. (b) Falso: esta es la trampa. Un replay es una ejecución nueva que vuelve a ejecutar cada paso desde arriba, así que la memoización de la ejecución antigua no se traslada. Lo que evita que un efecto secundario reproducido se dispare dos veces es la clave de idempotencia, no la memoización. (c) Verdadero: esta es la ruta que preserva la memoización, y es la que observaste en la Victoria rápida. El paso completado queda en un intento mientras el paso que falla reintenta. (d) Falso: la cancelación respeta el límite de paso; el step.run actual termina (o falla) antes de que la ejecución termine. Esto evita escrituras rotas.

Prueba con IA
Walk through a recovery scenario with my AI coding assistant:

Yesterday at 14:00 we deployed a change to the worker's agent step.
A bug in the new code made the agent step throw on every run.
From 14:00 to 18:00, 47 customer-support runs failed at that step.

At 18:30 we noticed, fixed the bug, and re-deployed.

Use the dev-server MCP's grep_docs to find Inngest's replay docs,
then:

1. Outline the exact dashboard steps to identify the 47 failed runs.
2. Explain what a dashboard Replay does for one of those runs: is it
a fresh run from the top on the fixed code, or a resume that
reuses the old run's memo? What does that mean for the cost of
replaying all 47?
3. Confirm whether the customers will see one reply or several if a
replayed run re-sends the email, and name the mechanism that
keeps it to one (hint: it is not memo).
4. Identify ONE scenario in this story where you'd prefer to
bulk-cancel instead of replay, and explain why.

Concepto 15: puertas HITL con step.wait_for_event, el Invariante 1 en el runtime

El Invariante 1 de Agent Factory dice que el ser humano es el principal: la intención humana, no el juicio autónomo del agente, es lo que el runtime debe honrar en las decisiones de alto riesgo. Este es el único lugar donde una mente humana vuelve a entrar al bucle. En todos los demás lugares el sistema nervioso corre por sí solo, por reflejo; aquí se detiene y espera a una persona. En producción esto aparece como puertas de aprobación: el agente hace el análisis, redacta la acción, pero no ejecuta la acción hasta que un humano aprueba.

step.wait_for_event de Inngest (Concepto 8) es la expresión más limpia de esto en cualquier plataforma hoy. El agente corre hasta el punto de decisión, se suspende y espera un evento de aprobación. El humano revisa (en Slack, en una interfaz de administración, en el correo) y hace clic en aprobar o rechazar. El evento se dispara. La función se reanuda con el veredicto del humano y actúa en consecuencia. Esto es lo que significa basado en especificación en tiempo de ejecución: el sistema nervioso hace cumplir el plan, qué acción necesita un humano, en qué orden, con qué tiempo de espera. No vigila el razonamiento del agente; controla lo que al agente se le permite hacer.

@inngest_client.create_function(
fn_id="refund-with-hitl-gate",
trigger=inngest.TriggerEvent(event="customer/refund.investigated"),
concurrency=[inngest.Concurrency(limit=5)],
)
async def refund_with_gate(ctx: inngest.Context) -> dict[str, str]:
request_id = ctx.event.data["request_id"]
amount_cents = ctx.event.data["amount_cents"]

# Step 1: the agent's analysis (your worker, run durably)
analysis = await ctx.step.run(
"agent-investigates",
run_refund_investigation_agent,
request_id=request_id,
)

# Step 2: if the agent thinks refund is warranted AND amount > $100,
# gate behind human approval
needs_approval = analysis.recommends_refund and amount_cents >= 10_000

if needs_approval:
await ctx.step.run(
"notify-approver",
send_slack_approval_request,
request_id=request_id,
analysis=analysis,
amount_cents=amount_cents,
)

# === THE HITL GATE ===
approval = await ctx.step.wait_for_event(
"wait-for-human-approval",
event="refund/approval.decided",
timeout=timedelta(hours=24),
if_exp=f"async.data.request_id == '{request_id}'",
)

if approval is None:
# Timeout: no human responded in 24h. Escalate.
await ctx.step.run(
"escalate-timeout",
escalate_to_senior_reviewer,
request_id=request_id,
)
return {"status": "escalated_timeout"}

if not approval.data["approved"]:
await ctx.step.run(
"notify-rejected", notify_customer_rejected,
request_id=request_id,
)
return {"status": "rejected_by_human"}

# Either it was approved, or it didn't need approval
refund = await ctx.step.run(
"issue-refund", call_stripe_refund,
request_id=request_id, amount_cents=amount_cents,
)

await ctx.step.run(
"audit-approved-refund", audit_refund,
request_id=request_id, refund=refund,
approved_by="human" if needs_approval else "auto",
)

return {"status": "issued", "refund_id": refund["id"]}

Lo que ves en el código: una secuencia de pasos, con un wait_for_event en el medio. Lo que está pasando en tiempo de ejecución:

  • El agente corre (paso 1, de forma duradera).
  • La función decide si aplica la puerta (lógica en código, libre de efectos secundarios).
  • Si hay puerta: una notificación de Slack se dispara (paso 2, duradero). La función se suspende. Cero cómputo consumido hasta por 24 horas.
  • Un humano en Slack hace clic en Approve o Reject. El backend de administración llama a inngest_client.send con refund/approval.decided y el request_id.
  • Inngest hace coincidir el evento con la función suspendida (el filtro if_exp asegura que solo los request IDs coincidentes coincidan). La función se reanuda en la línea siguiente.
  • La función usa la decisión del humano para emitir el reembolso o notificar el rechazo. Ambas rutas auditan la decisión y a quien aprueba.

Esto es lo que hace a Inngest cualitativamente distinto de una cola-más-máquina-de-estados. El patrón HITL es una primitiva. El código de la función se lee de arriba abajo, con la puerta en línea. No hay callback, no hay restauración de estado, no hay un despacho if state == waiting_for_approval: .... El runtime maneja la mecánica de suspender/reanudar; tu código expresa la política.

La puerta de aprobación humana en tiempo de ejecución, en tres fases. Fase 1: el agente corre, investiga, redacta la acción y pide aprobación, luego se pausa. Fase 2: la función se suspende en step.wait_for_event a cero cómputo por el tiempo que haga falta, mientras un revisor humano la lee en Slack o en una interfaz de administración y hace clic en Approve o Reject. Fase 3: un evento de aprobación reanuda la función, que se ramifica: Approve emite el reembolso, Reject registra un reembolso bloqueado, y Timeout registra un reembolso bloqueado. Cada rama escribe una fila en audit_log. El humano es el principal: el agente propone, una persona decide.

Un curso posterior desarrolla el Invariante 1 de forma arquitectónica: intención humana, flujos de trabajo basados en especificación, la capa de gestor de Workers que decide qué puertas aplican a qué acciones. Este curso te da la primitiva en tiempo de ejecución. Cuando llegue esa capa de gestión, la puerta que implemente será exactamente este patrón de wait_for_event, solo que compuesto a escala de flota. Conocer la primitiva ahora significa que el patrón arquitectónico más tarde se lea como "una composición sensata" en lugar de "magia".

Esta es la pieza clave que construyes en la Decisión 5 de la Parte 4: la aprobación de reembolso, hecha duradera. El concepto aquí es la forma; el ejemplo trabajado lo conecta a una herramienta real needs_approval y prueba que el reembolso se dispara exactamente una vez.

Predice. Tienes una puerta HITL configurada con timeout=timedelta(hours=24). La solicitud de reembolso de un cliente llega a las 17:00 de un viernes. Ningún humano está en línea durante el fin de semana. El tiempo de espera de la puerta se dispara a las 17:00 del sábado. Tu manejador de tiempo de espera registra un reembolso bloqueado. El revisor lee la solicitud el lunes a las 9:00. Recorre la línea de tiempo: ¿cuántas ejecuciones de función estuvieron activas durante el fin de semana? ¿Cuánto cómputo cobró Inngest? Confianza 1-5.

La respuesta: cero ejecuciones de función activas durante el fin de semana. La función estaba suspendida: Inngest almacenó su estado, sacó la función de la memoria y esperó al evento o al tiempo de espera. Inngest no factura el tiempo suspendido. Cuando llegó el sábado a las 17:00 y se disparó el tiempo de espera, la función se reanudó por los pocos cientos de milisegundos que tardó en escribir la fila de auditoría de reembolso bloqueado, y luego se completó. El hecho de que el revisor no mire hasta el lunes no cuesta nada del lado del Worker. La economía de los flujos de trabajo HITL en Inngest es radicalmente distinta de las colas basadas en sondeo que te facturan por cada segundo de sondeo de "¿ya está aprobado?".

Prueba con IA
With my AI coding assistant: design a durable refund-approval gate.
Specification:

1. The agent investigates and decides a refund is warranted, but the
refund tool needs human approval before it runs.
2. The gate should:
- Notify the on-call reviewer with the agent's recommendation
- Wait up to 4 hours for the reviewer to approve or reject
- On approve: issue the refund.
- On reject: do not issue; record a blocked refund.
- On 4-hour timeout: do not issue; record a blocked refund.
3. Every branch (approve/reject/timeout) writes an audit row from a
small fixed set of action names, capturing what was decided.

Use the dev-server MCP's send_event to simulate each branch of
the reviewer's decision during testing.

Parte 4: el ejemplo trabajado, un Production Worker de atención al cliente

Aquí es donde construyes. Primero el Worker (un prompt), luego el sistema nervioso a su alrededor, una capa por prompt. Diriges a tu agente de programación con prompts cortos en español sencillo y él escribe el código; los fragmentos mostrados abajo son las pocas líneas clave de cada capa, no los archivos. La implementación completa se ejecutó de extremo a extremo contra un servidor de desarrollo en vivo y un modelo real, así que las formas que ves son las que corren. Si una firma se ve desconocida, tu agente revisa la documentación actual.

La forma: siete prompts, sobre la base que ya configuraste.

  • D0 construye el Worker en sí, independiente.
  • D1 hace que la ejecución del agente sea duradera.
  • D2 deja que un evento lo despierte.
  • D3 agrega un cron diario que hace fan-out.
  • D4 agrega control de flujo.
  • D5 es la pieza clave: una puerta duradera de aprobación humana sobre los reembolsos.
  • D6 prueba que el Worker sobrevive a un paso roto: reintenta sin rehacer el trabajo completado, y luego se recupera.

La Parte 4 construye el sistema nervioso una capa a la vez. D0 (izquierda) es el agente, construido una sola vez: piensa y actúa, con el OpenAI Agents SDK, dos herramientas, Neon Postgres y un registro de auditoría; no cambia nunca después de esto. Luego se agregan seis capas desde afuera: D1 Reflejo (hazlo duradero, envuelve la ejecución en step.run), D2 Sentido (despierta con un evento de correo de cliente), D3 Sentido (un cron diario que hace fan-out por cliente), D4 Equilibrio (control de flujo: concurrencia y throttle), D5 Puerta (la pieza clave, una puerta de aprobación duradera donde la mente vuelve a entrar) y D6 Prueba (rompe un paso y recupérate). Los sentidos lo despiertan, los reflejos lo mantienen correcto, el equilibrio lo mantiene en buen estado, y la puerta deja que un humano decida.

Antes de empezar. Tu entorno ya está configurado desde la Victoria rápida: abre la misma carpeta ai-agent-nervous-system, con las habilidades de Inngest y neon-postgres instaladas, tu OPENAI_API_KEY y tu DATABASE_URL de Neon en .env, tus tablas customers y audit_log aprovisionadas, y los tres servidores MCP (Neon, Context7, inngest-dev) conectados. Solo dos recordatorios:

  • El servidor de desarrollo está corriendo. Inícialo otra vez si lo cerraste: npx inngest-cli@latest dev en su propia terminal. El panel está en http://127.0.0.1:8288. (Cuando más tarde despliegues a Inngest Cloud, el nivel gratuito Hobby es $0 sin tarjeta de crédito; sus techos están en la Parte 5.)
  • Una nota de mayúsculas para las llamadas de MCP de abajo. Los nombres de las herramientas del servidor de desarrollo son snake_case (send_event, get_run_status, invoke_function), pero sus parámetros son camelCase (get_run_status toma runId, invoke_function toma functionId). El SDK de Python es snake_case en todo; solo los parámetros de las llamadas de MCP son camelCase.

El encargo

Construyes un pequeño Worker de atención al cliente y le das un sistema nervioso de Production Worker. El Worker lee sus clientes de muestra de la tabla customers de Neon (id, email, tier), redacta una respuesta cálida a un correo entrante, puede emitir un reembolso solo con aprobación humana, y escribe una fila de auditoría en la tabla audit_log de Neon por cada acción de un pequeño conjunto fijo: message_received, message_sent, refund_issued, refund_blocked. Los siete prompts luego agregan Inngest a su alrededor: un evento lo despierta, la llamada al agente corre de forma duradera, un cron diario hace fan-out de una comprobación de salud por cada cliente elegible, el control de flujo limita la concurrencia y el throttle, el reembolso se pausa en una puerta humana duradera, y una ruta de replay recupera ejecuciones fallidas.

Una nota sobre los prompts que siguen. Cada uno está escrito de la forma en que realmente se lo dirías a un agente de programación: corto, sencillo, confiando en que maneje el detalle. Funcionan pegados en frío, y mejor aún si primero le pides al agente que se oriente ("lee el proyecto y dime qué ves, luego pregúntame cualquier cosa que no quede clara antes de empezar") a medida que se acumulan los archivos. Los prompts son el destino; orientarse primero es la rampa de entrada.


D0: construye el Worker, independiente

Dónde estás: la base está abierta, el servidor de desarrollo está corriendo y tu almacén de Neon está aprovisionado, pero todavía no existe ningún Worker. Esta Decisión construye el Worker independiente; al final corre sobre un correo de muestra y escribe una fila de auditoría en Neon.

La base ya trae un AGENTS.md que tu agente leyó al abrir, así que conoce el proyecto; por eso estos prompts se mantienen cortos. La única regla en él que vale la pena tener en tu propia cabeza es el invariante arquitectónico de todo el curso: el propio código del Worker nunca importa de inngest. El agente y sus herramientas se quedan en Python simple; el sistema nervioso los envuelve desde afuera. Esa separación, el agente y el sistema nervioso mantenidos aparte, es lo que te permite intercambiar Inngest por Temporal o Restate más tarde y dejar el Worker intacto.

Tu sistema de registro Neon ya está aprovisionado desde la Victoria rápida: las tablas customers y audit_log existen, y DATABASE_URL está en tu .env. Así que el Worker lee y escribe esa base de datos desde el inicio. Ahora construye el Worker. Pega esto:

Constrúyeme un agente de atención al cliente mínimo con el OpenAI Agents SDK, corriendo en un sandbox local. Lee los clientes de muestra de mi tabla customers de Neon (cada fila tiene un id, un email y un tier), redacta una respuesta cálida a un correo entrante de un cliente, y puede emitir un reembolso, pero la herramienta de reembolso necesita aprobación humana antes de correr. Escribe una fila de auditoría en mi tabla audit_log de Neon por cada acción, usando un pequeño conjunto fijo de nombres de acción y la DATABASE_URL de .env. Siembra la tabla customers con cinco filas de muestra primero si está vacía. Mantenlo pequeño; existe para ser envuelto, no para enviarse a producción. Luego córrelo sobre un correo de muestra y muéstrame la respuesta.

Crea: worker.py y db.py (un proyecto plano, sin anidamiento en src/). D1 agrega el host de Inngest como el tercer archivo. El agente alcanza Postgres a través de DATABASE_URL, nunca a través del servidor MCP de Neon, que es tu herramienta solo de tiempo de construcción.

Los datos de siembra son lo bastante pequeños para mantenerlos en la página, cinco clientes de muestra en tres niveles, que el agente inserta en la tabla customers:

[
{ "id": "cust_001", "email": "ada@example.com", "tier": "enterprise" },
{ "id": "cust_002", "email": "grace@example.com", "tier": "pro" },
{ "id": "cust_003", "email": "linus@example.com", "tier": "pro" },
{ "id": "cust_004", "email": "edsger@example.com", "tier": "standard" },
{ "id": "cust_005", "email": "alan@example.com", "tier": "standard" }
]

Tu agente escribe dos archivos Python cortos. db.py contiene el acceso a Postgres: una pequeña conexión asyncpg con pool sobre DATABASE_URL, una lectura load_customers() y un ayudante de escritura de auditoría record() con un vocabulario cerrado, cualquier nombre de acción fuera del conjunto de cuatro elementos lanza, lo que convierte un error tipográfico en un error ruidoso en lugar de una fila mala silenciosa. worker.py es un SandboxAgent con dos herramientas que llaman a db.py. Solo una línea es clave para el resto del curso, el decorador de la herramienta de reembolso:

@function_tool(needs_approval=True)
def issue_refund(order_id: str, amount_cents: int, reason: str) -> str:
...

Ese needs_approval=True hace que el agente se pause en lugar de emitir el reembolso: la ejecución vuelve con el reembolso pendiente y un humano decide. Es el gancho del que cuelga toda la pieza clave HITL (D5). (Este piso pone puerta a cada reembolso, lo que mantiene la pieza clave simple; un Worker de producción normalmente pondría puerta solo por encima de un umbral, el patrón de más-de-$100 del Concepto 15. El cableado es idéntico de cualquier forma.)

Una nota estructural a confirmar en lo que el agente escribe, porque D5 depende de ello: mantén build_agent() y el run_config() del sandbox como funciones separadas. Cuando D5 reanuda una ejecución pausada, reconstruye el agente a la misma forma de herramientas y vuelve a pasar el mismo run_config(); el estado guardado no lleva la sesión del sandbox, así que la reanudación debe volver a suministrarla. Sepáralas ahora y la pieza clave será un paso pequeño más tarde.

Listo cuando: el agente corre sobre un correo de muestra e imprime una respuesta corta, y hay una nueva fila en la tabla audit_log de Neon (revísala en la consola, o pídele a tu agente que la lea de vuelta por las herramientas de Neon). Si el correo describe un reembolso, la ejecución se pausa en la herramienta de reembolso en lugar de emitirlo; esa pausa es el punto entero, y D5 la hace duradera.

El modelo de tu agente de programación importa aquí

Los prompts de esta Parte asumen un agente de programación de clase frontera (Claude Sonnet u Opus, un modelo de clase GPT-5, o Gemini 2.5 Pro). La arquitectura de Inngest que estás aprendiendo (eventos, pasos, memoización, control de flujo) es de nivel SDK y se sostiene con cualquier modelo que conduzca tu agente. Pero la experiencia de construcción se apoya en un fuerte seguimiento de instrucciones, especialmente la pieza clave D5. En un modelo más débil, espera iterar un prompt más de una vez y deletrear los nombres de archivo. La arquitectura no está rota; el prompting solo necesita más andamiaje.


D1: haz que la ejecución del agente sea duradera

Dónde estás: un Worker que corre solo cuando lo llamas, perdiéndolo todo en un fallo a mitad de ejecución. Esta Decisión envuelve la llamada al agente en step.run; al final una ejecución completada muestra el paso del agente memoizado en el panel.

El sistema nervioso empieza aquí: envuelve toda la llamada al agente en un solo step.run para que sea duradera y memoizada. Pega esto:

Envuelve la ejecución del agente en una función duradera de Inngest para que sobreviva a fallos y reintente fallos transitorios. Toda la llamada al agente va dentro de un solo step.run para que se memoice. Córrela en modo de desarrollo local contra el servidor de desarrollo de Inngest, con un host de FastAPI. Confirma que una ejecución completada muestra el paso del agente memoizado en el panel.

Crea: inngest_app.py (un cliente de Inngest en modo de desarrollo, la llamada al agente en un ayudante, y un host de FastAPI que el servidor de desarrollo descubre).

La forma que importa es un step.run envolviendo la llamada al agente:

async def handle_customer_email(ctx: inngest.Context) -> dict:
email_text = ctx.event.data["email_text"]
outcome = await ctx.step.run("run-agent", functools.partial(_run_agent, email_text))
return {"replied": outcome["status"] == "done"}

Dos modismos a confirmar en lo que el agente escribe. El manejador del paso no toma argumentos propios, así que functools.partial enlaza email_text de antemano, así es como pasas datos a cualquier paso, y lo verás en cada paso de aquí en adelante. Y el ayudante del agente usa Runner.run simple, no un runner con streaming: es la ruta sobre la que se construye la pieza clave de aprobación humana (D5), así que usarla desde el inicio hace de D5 un paso pequeño en lugar de una reescritura. El cliente se construye is_production=False (el flag de modo de desarrollo de la Victoria rápida).

Córrelo como dos procesos, el host de funciones y el servidor de desarrollo que lo encuentra:

uv run uvicorn inngest_app:app --port 8000 --reload --log-level info  # terminal 1: function host (your model key is sourced here; --reload picks up the D6 break/fix edits)
npx inngest-cli@latest dev -u http://127.0.0.1:8000/api/inngest # terminal 2: dev server, auto-discovers the host

Listo cuando: el panel lista handle-customer-email y una ejecución completada muestra el paso run-agent. (Lo despiertas como corresponde con un evento en D2; por ahora, que la función sea descubrible es suficiente.)

Por qué este es el movimiento clave. La llamada al agente es la parte costosa: tokens del modelo, varios segundos. Dentro de step.run su resultado se memoiza, así que cuando un paso posterior falla y la función reintenta, el agente no corre de nuevo. Ese único envoltorio es la diferencia entre un Worker que paga y actúa doble en cada reintento y uno que hace cada cosa costosa exactamente una vez.


D2: dispáralo con un evento

Dónde estás: una función duradera ya disparada por customer/email.received (el decorador de D1), pero sin registro de auditoría. Esta Decisión agrega una fila de auditoría a cada lado del agente; al final un evento real conduce una ejecución con ambas filas escritas.

Agrega un paso de auditoría antes del agente y uno después, luego despierta al Worker con un evento real en lugar de correrlo a mano. Pega esto:

Haz que el Worker despierte con un evento customer/email.received en lugar de correrse a mano. Agrega un paso de auditoría de entrada antes del agente y un paso de auditoría de respuesta después. Envía un evento de prueba y muéstrame la ejecución completándose con ambas filas de auditoría.

Edita: inngest_app.py (la función gana un paso de auditoría a cada lado del agente).

La forma son dos llamadas step.run más alrededor del paso del agente:

customer_id = ctx.event.data.get("customer_id")  # bound from the event, alongside D1's email_text
await ctx.step.run("audit-received", functools.partial(
db.record, "message_received", customer_id=customer_id, detail=email_text[:80]))
outcome = await ctx.step.run("run-agent", functools.partial(_run_agent, email_text))
await ctx.step.run("audit-sent", functools.partial(
db.record, "message_sent", customer_id=customer_id, detail=(outcome["reply"] or "")[:80]))

Cada fila usa un nombre de acción del conjunto cerrado: message_received al entrar, message_sent al salir, y db.record la escribe en la tabla audit_log de Neon sobre DATABASE_URL. Envía el evento de prueba desde el agente con la herramienta send_event del MCP del servidor de desarrollo (name: "customer/email.received", un objeto data con email_text y customer_id). El servidor de desarrollo acepta cualquier evento, así que no configuras ningún webhook para probar localmente; en producción apuntarías tu proveedor de correo a una URL de webhook de Inngest que reformatea su carga útil en este evento, lo que es un ajuste del panel, no código.

Listo cuando: la ejecución se completa, la traza muestra tres pasos en orden (audit-received, run-agent, audit-sent), y la tabla audit_log de Neon tiene una fila message_received y una fila message_sent para ese cliente.

Por qué dos pasos de auditoría, no uno. Cada uno es su propio step.run, así que cada uno se memoiza de forma independiente. Si el paso de respuesta falla y la función reintenta, la fila de entrada no se escribe dos veces (acierto de memoización) y el agente no corre dos veces (también memoizado). El registro de auditoría se mantiene exactamente-una-vez a través de los reintentos, la propiedad que D6 probará.


D3: un cron diario que hace fan-out

Dónde estás: un Worker que el mundo despierta un correo a la vez. Esta Decisión agrega un cron diario que hace fan-out de un evento por cada cliente elegible; al final cada uno obtiene su propia ejecución hija duradera.

Agrega trabajo programado: un cron diario que dispara un evento de comprobación de salud por cada cliente Pro y Enterprise, cada evento disparando su propia ejecución duradera. Pega esto:

Agrega un cron diario que haga fan-out de un evento customer/health_check.requested por cada cliente Pro y Enterprise, cada uno con clave de idempotencia para que una ejecución de cron reentregada nunca dispare doble. Cada evento hijo dispara su propia ejecución duradera que escribe una fila de auditoría. Invoca el cron manualmente y muéstrame una ejecución hija por cada cliente elegible.

Crea: un padre cron que hace fan-out y un consumidor de eventos que maneja cada hijo, ambos registrados con el host.

Dos formas llevan esta Decisión. El disparador es un decorador cron de una línea, y el fan-out son N eventos cada uno llevando una clave de idempotencia:

@inngest_client.create_function(fn_id="daily-health-check", trigger=inngest.TriggerCron(cron="0 9 * * *"))
async def daily_health_check(ctx: inngest.Context) -> dict:
# ... select Pro/Enterprise customers, then:
events = [
inngest.Event(
name="customer/health_check.requested",
data={"customer_id": c["id"]},
id=f"health-{c['id']}-{ctx.event.id}", # idempotency key per (customer, cron run)
)
for c in eligible
]
await ctx.step.send_event("fan-out-health-checks", events)

La clave de idempotencia es el detalle clave: id=f"health-{customer}-{cron_run}" significa que si la misma ejecución de cron se entrega dos veces (un redespliegue, un reintento), el evento duplicado se descarta, así que cada cliente obtiene exactamente una comprobación por día. El consumidor es una función ordinaria disparada por evento que escribe una fila de auditoría. Invoca el cron desde el agente con la herramienta invoke_function del MCP (no esperes a las 09:00 de mañana). Una peculiaridad de desarrollo: el servidor de desarrollo solo dispara crons mientras está corriendo; producción los corre en la infraestructura siempre activa de Inngest.

Listo cuando: el padre se completa en segundos y el panel muestra una ejecución hija customer-health-check por cada cliente elegible, con los clientes de nivel standard correctamente omitidos.

Por qué fan-out, no un bucle. El padre no procesa a los clientes él mismo; envía N eventos y retorna. Cada hijo es su propia ejecución, aislada, reintentable de forma independiente, limitada por su propia concurrencia. Un bucle dentro de una función los acoplaría: un cliente lento retiene al resto, y un fallo pierde todo el lote. El fan-out es cómo un solo despertar programado se convierte en N ejecuciones duraderas independientes.


D4: control de flujo

Dónde estás: un Worker que maneja cada correo pero los dispararía todos a la vez bajo una ráfaga. Esta Decisión agrega tres políticas de control de flujo; al final una ráfaga de veinte eventos se encola bajo el tope sin filas descartadas ni duplicadas.

Cuando quinientos correos llegan a las 9am, el Worker no debería disparar quinientas llamadas al modelo a la vez: eso revienta el límite de velocidad y deja sin capacidad a todos los que están detrás del cliente parlanchín. Agrega un tope de concurrencia global, un tope por cliente y un throttle. Pega esto:

Agrega control de flujo al manejador de correos: un tope de concurrencia global, una clave de concurrencia por cliente para que un cliente parlanchín no deje sin capacidad al resto, y un throttle para proteger el límite de velocidad de OpenAI. Dispara una ráfaga de veinte eventos a través de cinco clientes y muéstrame que se encolan bajo el tope y todos se completan sin filas de auditoría descartadas ni duplicadas.

Edita: inngest_app.py (tres argumentos del decorador en la función de correo).

Estos tres argumentos son la lección, todo D4 vive en ellos:

concurrency=[
inngest.Concurrency(limit=10), # global cap
inngest.Concurrency(limit=2, key="event.data.customer_id"), # per-customer cap
],
throttle=inngest.Throttle(limit=100, period=datetime.timedelta(minutes=1)),

Tres perillas, tres trabajos. El limit=10 global limita cuántas ejecuciones corren a la vez, protegiendo dos techos reales: el límite de velocidad del modelo y tu presupuesto de conexiones de Neon. Dos cosas acotan tus conexiones, y trabajan a escalas distintas. Dentro de una sola réplica del Worker, todas las ejecuciones comparten un pool asyncpg, así que el max_size del pool es lo que mantiene las conexiones planas sin importar cuántas ejecuciones estén activas (una ráfaga de veinte ejecuciones en un host igual va sobre un puñado de conexiones del pool). A través de réplicas, ese pool local ya no ayuda, la réplica dos tiene su propio pool, así que el tope de concurrencia es lo que acota las ejecuciones totales, y por tanto las conexiones totales, en toda la flota: diez réplicas a limit=10 cada una son cien ejecuciones y unas cien conexiones, que dimensionas contra el presupuesto de Neon (el nivel gratuito permite unos cientos en pool). El pool y el tope juntos son la protección: el pool acota una réplica, el tope acota la flota. Sin ninguno de los dos, una ráfaga de quinientos correos a través de réplicas sin pool y sin tope abre muchas más conexiones de las que Neon aceptará. El limit=2 por cliente con clave en event.data.customer_id significa que la ráfaga de un cliente ocupa como máximo dos ranuras, así que una inundación de una cuenta nunca deja sin capacidad a las demás. El throttle limita cuántas ejecuciones inician por minuto, suavizando un pico en una tasa estable. Una función lleva como máximo dos políticas de concurrencia; el par global-más-por-clave es la forma común. Dispara la ráfaga desde el agente: veinte eventos customer/email.received a través de cinco clientes vía send_event.

Listo cuando: la ráfaga se encola bajo el tope (el conteo en ejecución se mantiene en 10 o por debajo, y en 2 o por debajo por cliente), cada ejecución se completa, y la tabla audit_log de Neon tiene exactamente veinte filas message_received y veinte message_sent. Sin ejecuciones descartadas, sin duplicados, y sin errores de límite de conexión de Neon bajo la ráfaga, en este host único el pool asyncpg mantiene las conexiones planas (verás solo un puñado en uso aun con la ráfaga corriendo), y el tope es lo que las mantendría planas a través de réplicas una vez que escales horizontalmente.

Por qué esto es política, no código. Nada de esto vive en el cuerpo de tu función; es configuración que el runtime hace cumplir. Sin los topes, una ráfaga o derrite un sistema posterior o deja que un inquilino monopolice al Worker. Escribir la misma equidad a mano es una cola más un programador más un limitador de velocidad, cientos de líneas. Aquí son tres argumentos del decorador.


D5: una puerta duradera de aprobación humana sobre los reembolsos (la pieza clave)

Dónde estás: un Worker cuya pausa de reembolso (el needs_approval=True de D0) es efímera, viviendo en el proceso en ejecución. Esta Decisión hace esa pausa duradera; al final la ejecución se suspende a cero cómputo, espera un evento de aprobación real y se reanuda para emitir el reembolso exactamente una vez.

Esa pausa efímera es la brecha: un fallo, un despliegue o un revisor que se toma la tarde, y el reembolso pendiente desaparece. Esta es la pieza clave de todo el curso: hacer la pausa duradera, para que la función se suspenda a cero cómputo, espere un evento de aprobación real por el tiempo que haga falta, y luego reanude exactamente la misma ejecución del agente. Pega esto:

La aprobación de reembolso es actualmente una pausa en proceso que un fallo o un revisor lento perdería. Hazla duradera: cuando el agente se pause en el reembolso, persiste su estado de ejecución serializado como la salida del paso, luego suspende toda la función en step.wait_for_event esperando un evento refund/approval.decided (dale un tiempo de espera de cuatro horas y hazlo coincidir con este cliente). Cuando llegue la decisión, rehidrata el estado, aplica aprobar o rechazar, y reanuda el agente para que el reembolso se dispare exactamente una vez. Conduce un reembolso, muéstrame la ejecución suspendida y en espera, envía una aprobación, y muéstrame exactamente una fila de auditoría de reembolso. Luego hazlo otra vez con un rechazo y muéstrame una fila bloqueada y ningún reembolso.

Edita: inngest_app.py (los ayudantes del agente aprenden a pausar y reanudar; la función de correo gana la puerta).

Esta Decisión gana más código que las otras, porque la danza de suspender-y-reanudar es la lección. Cuando el agente se pausa, serializa su estado de ejecución; cuando llega la decisión, rehidratas ese estado, aplicas aprobar o rechazar, y reanudas:

async def _run_agent(email_text: str) -> dict:
agent = worker.build_agent()
result = await Runner.run(agent, email_text, run_config=worker.run_config())
if result.interruptions: # the refund tool paused for approval
return {"status": "needs_approval", "state": result.to_state().to_string()}
return {"status": "done", "reply": result.final_output}


async def _resume_agent(state_str: str, approved: bool, rejection_message: str | None) -> dict:
agent = worker.build_agent()
state = await RunState.from_string(agent, state_str)
for item in state.get_interruptions():
if approved:
state.approve(item)
else:
state.reject(item, rejection_message=rejection_message or "Refund denied.")
db.record("refund_blocked", detail=f"args={item.arguments}")
result = await Runner.run(agent, state, run_config=worker.run_config())
return {"status": "resumed", "reply": result.final_output}

Dentro de la función de correo, la puerta es un wait_for_event en línea donde el agente se pausó; la decisión conduce un paso de reanudación:

decision = await ctx.step.wait_for_event(
"await-refund-approval",
event="refund/approval.decided",
timeout=datetime.timedelta(hours=4),
if_exp=f"async.data.customer_id == '{customer_id}'",
)
# (decision is None on timeout -> write a refund_blocked row and return)
resumed = await ctx.step.run("resume-agent", functools.partial(
_resume_agent, outcome["state"], bool(decision.data.get("approved")), decision.data.get("rejection_message")))

Léelo de arriba abajo: la puerta es una llamada en línea en una función por lo demás ordinaria. No hay callback, no hay despacho de máquina de estados, no hay ramificación if status == waiting: a través de invocaciones. El runtime maneja suspender y reanudar; tu código expresa la política. Cuatro detalles se ganan su lugar:

  • result.to_state().to_string() serializa la ejecución pausada, y se convierte en la salida del paso run-agent, así que se almacena de forma duradera. to_state() es sincrónico; to_string() devuelve la cadena que persistes.
  • RunState.from_string(agent, s) se espera con await (es una corrutina) y toma esa cadena almacenada directamente. Luego haces approve o reject sobre state.get_interruptions() y llamas a Runner.run(agent, state, ...) para reanudar. (Una reanudación puede dejar aprobaciones pendientes, así que el ayudante real itera hasta que no quede ninguna.)
  • El mismo run_config() se vuelve a pasar al reanudar, y el agente se reconstruye a la misma forma de herramientas. El estado serializado no lleva la sesión del sandbox, así que la reanudación debe volver a suministrarla. Este es el único detalle que, si se omite, hace que la ejecución reanudada falle. (D0 separó build_agent y run_config exactamente por esto.)
  • if_exp hace coincidir la decisión con este cliente (async.data.customer_id == '...'), así que una aprobación para un cliente nunca reanuda la ejecución de un cliente distinto.

Para conducirlo desde el agente: envía un evento customer/email.received cuyo correo describa un reembolso, observa cómo la ejecución se suspende en await-refund-approval (el panel la muestra WAITING, con el estado de ejecución RUNNING pero cero cómputo), luego envía refund/approval.decided con {"approved": true, "customer_id": "cust_001"} vía send_event. Hazlo otra vez con {"approved": false}.

Listo cuando: al aprobar, la ejecución suspendida se reanuda y la tabla audit_log de Neon tiene exactamente una fila refund_issued. Al rechazar, la ejecución se reanuda, la auditoría tiene una fila refund_blocked y ninguna refund_issued, y la respuesta del agente explica la denegación.

Por qué esta es la pieza clave. Cada otra capa (los sentidos, los reflejos, el equilibrio) mantiene al Worker correcto o en buen estado por sí sola. Esta es donde la mente humana vuelve a entrar al bucle en una acción de alto riesgo, de forma duradera, por el tiempo que haga falta, a costo cero mientras espera. Una versión de cola-más-base-de-datos-más-sondeo de esto es un pequeño proyecto. Aquí es un wait_for_event y una reanudación.


D6: prueba que la durabilidad sobrevive a un paso roto

Dónde estás: un Worker completo con cada capa envuelta. Esta Decisión prueba la propiedad que justificó todo lo demás; al final has observado cómo una ejecución rota reintenta su paso que falla muchas veces mientras su paso de auditoría completado corre exactamente una vez, y luego recuperaste el trabajo en una ejecución nueva.

La última propiedad por probar es la que justificó todo esto, la mecánica de memoización del Concepto 7. La entendiste allí; ahora pruébala en tu propio Worker. Pega esto:

Rompe deliberadamente el paso del agente para que falle, dispara un evento, y muéstrame a Inngest reintentándolo mientras el paso de auditoría anterior se mantiene memoizado, de modo que la ejecución que falla escriba su fila de auditoría de entrada exactamente una vez a través de todos los reintentos del agente. Luego arregla el paso y recupera el trabajo, y muéstrame la recuperación completándose.

Rompe el paso del agente a propósito (lanza un ValueError dentro de _run_agent), dispara unos cuantos eventos customer/email.received para clientes distintos, y lee la traza de cada ejecución. Esta es la prueba, y está dentro de cada ejecución que falla: audit-received muestra un intento completado y escribe su fila una vez; run-agent muestra varios Attempts mientras reintenta con backoff (Inngest usa por defecto varios intentos) y luego falla; audit-sent nunca corre. El paso de auditoría quieto en un intento mientras el paso del agente sube es la memoización del Concepto 7, ahora visible en tu propio Worker: la ejecución que falla escribe solo una fila message_received sin importar cuántas veces reintente el paso del agente.

Luego revierte la rotura (el host se recarga automáticamente si lo corriste con --reload; si no, reinícialo) y recupera el trabajo volviendo a disparar el evento sobre el código corregido (o, para un lote de mal despliegue real, el botón Rerun del panel; ambos inician una ejecución nueva desde arriba, cubierto en el Concepto 14). Aquí está la parte que sorprende a la gente, y es comportamiento correcto, no un error: la recuperación es una ejecución nueva, así que corre audit-received otra vez y escribe su propia fila message_received. Después de un romper-luego-recuperar, ese cliente legítimamente tiene dos filas message_received, una de la ejecución fallida, una de la recuperación. La memoización es una garantía dentro de una ejecución; nunca abarca dos ejecuciones separadas.

Listo cuando: en la traza de la ejecución fallida, audit-received se quedó en un intento y escribió una fila mientras run-agent acumuló varios intentos y falló, ese un-intento-a-pesar-de-N-reintentos es la memoización, probada. Luego la ejecución de recuperación completa run-agent y audit-sent sobre el código corregido. Consulta la audit_log de Neon en la consola (o pídele a tu agente que la lea de vuelta por las herramientas de Neon): un cliente que rompiste-y-recuperaste tendrá dos filas message_received (la ejecución fallida más la recuperación) y una message_sent (solo la recuperación llegó tan lejos), lo cual es exactamente correcto. El diagnóstico real es por ejecución, no por cliente: abre la traza de una sola ejecución y confirma que audit-received muestra un intento. Si la traza de una ejecución muestra el paso de entrada corriendo dos veces, eso es un error de memoización (normalmente un nombre de paso no único); dos filas repartidas en dos ejecuciones separadas no lo es.

Por qué esta es la línea divisoria. Un Worker que pierde trabajo de clientes en un mal despliegue es solo un agente que llamas. Un Worker que recibe el mismo mal despliegue, falla ruidosamente, reintenta el paso roto sin rehacer el trabajo que ya terminó (los muchos intentos del paso del agente, pero la auditoría de entrada escrita una vez), y se recupera limpiamente en una ejecución nueva tras el arreglo, es un Production Worker. La prueba es la traza de la propia ejecución fallida, un intento de entrada contra muchos intentos del agente, no un conteo de filas a través de ejecuciones.

¿Hiciste el curso Digital FTE?

Apunta este mismo sistema nervioso a tu propio Worker SandboxAgent en lugar del piso mínimo; el envoltorio es idéntico. Y esta aprobación con step.wait_for_event reemplaza la tabla de estado de ejecución hecha a mano de la Decisión 10 opcional de aquel curso: la puerta duradera que acabas de construir es la capa de persistencia, así que puedes eliminar la tabla.


Lo que acaba de pasar

Construiste un pequeño Worker de atención al cliente y le diste un sistema nervioso, una capa a la vez. Las partes internas del Worker nunca cambiaron después de D0: el mismo SandboxAgent, las mismas dos herramientas, el mismo registro de auditoría en Neon Postgres. Lo que cambió es todo lo que está alrededor. Ahora despierta con un evento customer/email.received y con un cron diario que hace fan-out por cada cliente elegible, corre de forma duradera (la llamada al agente dentro de step.run), respeta el control de flujo (concurrencia global y por cliente, un throttle), pone puerta a los reembolsos con una aprobación humana duradera (step.wait_for_event), y se recupera de un mal despliegue reproduciendo ejecuciones fallidas, con el registro de auditoría mostrando que, dentro de cualquier ejecución, cada paso se disparó exactamente una vez, sin importar cuántas veces reintentó esa ejecución.

El código del agente es el mismo; su alcance no. Empezaste con un agente que tú operas, lo promptas, lo observas, lo vuelves a promptar. Ahora tienes un Worker que opera por sí solo: el mundo lo despierta, sus reflejos lo llevan a través de los fallos, mantiene su equilibrio bajo carga, y un humano interviene solo donde las apuestas lo exigen. Esa es la línea que trazó la apertura, entre un agente que tú operas y un FTE que opera por sí solo, y acabas de construir al otro lado de ella.

Las preocupaciones que quedan son la observabilidad a escala, la coordinación multi-Worker, y la capa de gestión que decide qué Workers manejan qué tráfico. Ese es el siguiente curso de la ruta. Este curso cubre la unidad de ejecución lista para producción; el siguiente compone esas unidades en una fuerza laboral.


Parte 5: dónde termina este curso

La forma de costo de un Production Worker

Importan dos superficies de costo: el costo de infraestructura (Inngest, y el almacén y el cómputo donde corres el Worker) y el costo de inferencia (tokens del modelo). La infraestructura se mantiene aproximadamente plana a medida que aumenta la carga; la inferencia escala linealmente. El método de abajo es lo que hay que aprender; cualquier cifra en dólares se vuelve obsoleta la semana en que se publica, así que trata los números como ilustrativos y consulta las páginas de precios actuales antes de poner una cifra en un presupuesto.

Precios de Inngest. Inngest cobra por ejecución: cada ejecución de función, más cada reintento a nivel de paso, cuenta como una ejecución.

NivelPrecioEjecuciones / mesPasos concurrentesNotable
Hobby$050 00053 usuarios, 50 conexiones en tiempo real, sin tarjeta de crédito
Prodesde $75 / mes1 000 000100+1000+ conexiones en tiempo real, 15+ usuarios, retención de trazas de 7 días
Enterprisea medidaa medida500-50 000SAML / RBAC, retención de trazas de 90 días, soporte dedicado

El precio de eventos se suma encima: los primeros 1-5M de eventos por día están incluidos; por encima de eso, el excedente empieza alrededor de $0.000050 por evento y baja a mayor volumen. Pro agrega $50 por cada 1M de ejecuciones adicionales cuando rebasas el tope de 1M.

Techos del nivel Hobby que importan aquí. El tope de 5 pasos concurrentes significa que aunque declares concurrency=Concurrency(limit=10) en el código, el tope a nivel de cuenta de la plataforma te retiene en 5. Tu código es correcto para producción; la concurrencia observada en el nivel gratuito es 5. step.sleep y step.sleep_until también están acotados por nivel: hasta siete días en el plan gratuito Hobby, hasta un año en planes de pago (límites de uso de Inngest).

El costo de inferencia domina. Una ejecución típica de atención al cliente usa de unos pocos miles a diez mil tokens del modelo por conversación. Multiplica tu precio por token por tus tokens por correo por tus correos por día y tienes la línea que importa; para la mayoría de los Workers eclipsa todo lo demás. Esto es lo que optimizas. Todo lo demás es un error de redondeo. Las dos palancas de mayor valor: mantén un prefijo de prompt en caché estable (para que el modelo facture la parte repetida a la tarifa más barata de caché, no a precio completo en cada llamada), y enruta los turnos fáciles a un modelo más barato.

Tres palancas de costo específicas de Inngest una vez que estás en la zona de optimización:

  • No envuelvas funciones puras en step.run. Si una función no tiene efectos secundarios, no necesita durabilidad; envolverla agrega un cargo de step-run sin beneficio. Reserva step.run para E/S y efectos secundarios.
  • Usa batch_events para rutas masivas. Un lote de 50 eventos es una ejecución de función, no 50.
  • Suspende barato con step.sleep y step.wait_for_event. Las funciones suspendidas no facturan el tiempo de suspensión. Un seguimiento retrasado de 3 días cuesta lo mismo que uno de 3 segundos.

La forma a escala: la inferencia es la factura que crece con el tráfico; Inngest, tu almacén de datos y el cómputo se mantienen comparativamente planos. Haz la misma multiplicación con tu volumen real en lugar de confiar en una cifra impresa aquí.


Guía de intercambio: el sistema nervioso es invariante, la plataforma no

Este curso nombra Inngest en cada capa. Eso es porque un ejemplo didáctico necesita respuestas concretas, no "usa el orquestador que quieras". Pero la arquitectura funciona con cualquier alternativa compatible. Cinco intercambios que el diseño del curso anticipa explícitamente:

  • Superficie de disparo: eventos de Inngest → señales de Temporal, manejadores de Restate, AWS EventBridge + Lambda. Cada plataforma tiene una forma de expresar "este código corre cuando esta cosa con nombre ocurre". Los nombres de eventos, las formas de las cargas útiles y la disciplina de idempotencia se transfieren todos. Lo que cambia: la sintaxis del decorador del SDK y el panel.

  • Ejecución duradera: step.run de Inngest → actividades de Temporal, manejadores de Restate, máquinas de estados personalizadas respaldadas por Postgres. Cada una te da la semántica de "memoiza esta llamada con efectos secundarios, reintenta ante fallo transitorio, reanuda tras fallo". Temporal es el análogo más cercano y la opción más antigua y más probada en empresas. Restate es la más nueva y tiene un sabor más de programación funcional. Las máquinas de estados personalizadas son lo que los equipos escriben cuando no pueden adoptar una plataforma gestionada; normalmente de 1000 a 10 000 líneas de código que recrean ~70% de lo que Inngest te da gratis.

  • Primitiva HITL: step.wait_for_eventawait Workflow.execute_activity(approval_signal) de Temporal, awakeables de Restate, colas de aprobación personalizadas con Redis/Postgres. El patrón es el mismo: la función se suspende, una señal externa la reanuda, la auditoría captura la decisión. La expresión de Inngest es la más limpia para escribir; la de Temporal es más verbosa pero probada en batalla a gran escala.

  • Programación cron: disparadores cron de Inngest → CronJobs de Kubernetes + cola, programaciones de GitHub Actions, programaciones de AWS EventBridge. Los disparadores cron son una mercancía. La ventaja de Inngest no es tener cron; es que las funciones disparadas por cron obtienen la misma durabilidad/replay/control-de-flujo que las disparadas por evento, automáticamente. Otras plataformas te hacen cablear eso tú mismo.

  • Control de flujo: concurrencia + throttle de Inngest → colas de tareas de Temporal con concurrencia de workers, limitadores de velocidad respaldados por Redis, tiempos de espera de visibilidad de mensajes de AWS SQS. Otras plataformas pueden hacer esto; Inngest lo hace con la densidad de configuración que hemos visto (un argumento del decorador).

Dapr como el compañero abierto a escala de producción. Un reemplazo más ambicioso que vale la pena nombrar: Dapr Agents como el compañero estructural de Inngest a escala de producción, de la manera en que OpenCode lo es de Claude Code. Dapr Agents alcanzó v1.0 GA el 23 de marzo de 2026 bajo gobernanza de la CNCF (anuncio de la CNCF, conceptos centrales de Dapr Agents). DurableAgent es la clase lista para producción; la clase Agent más antigua está obsoleta. Elige Dapr cuando el despliegue nativo de Kubernetes y los SDK multilenguaje importen más que la experiencia de desarrollo local de Inngest. Inngest es la mejor herramienta de aprendizaje (el panel hace visible el modelo mental); Dapr es la mejor herramienta de escala cuando has alcanzado los techos de nivel de Inngest o necesitas despliegue multilenguaje nativo de K8s.

Inngest también es de código abierto (github.com/inngest/inngest; la versión 1.0 agregó soporte de autohospedaje en septiembre de 2024) y autohospedable vía Helm + KEDA. Los ejes que importan a escala son gobernanza, soporte y madurez: Inngest está gobernado por un solo proveedor con una historia de autohospedaje joven; Dapr está gobernado por la CNCF con un historial de producción más largo.

Concepto de este cursoPrimitiva de InngestAnálogo de producción en DaprNota didáctica
Trabajo programadoTriggerCronBinding de entrada cron / Programador de DaprMisma idea: el tiempo despierta al Worker. Dapr normalmente requiere configuración de componentes.
Entrada de webhook/eventoEndpoint de webhook de Inngest → eventoEndpoint HTTP, bindings de entrada, o entrada por pub/subInngest oculta más cableado; Dapr da control de infraestructura.
Eventos internosinngest_client.send()Pub/sub de DaprMismo modelo mental basado en eventos; el broker es intercambiable en Dapr.
Fan-outUn evento dispara muchas funcionesUn topic/evento consumido por muchos serviciosMisma arquitectura; Dapr usa composición de broker/topic/suscriptor.
Pasos duraderosstep.run() + memoizaciónWorkflows de Dapr + actividadesPropósito de producción similar, modelo de desarrollo distinto.
Esperar sin cómputostep.sleep()Temporizadores de workflow duraderosAmbos evitan mantener un proceso abierto mientras esperan.
Puerta de aprobación humanastep.wait_for_event()Eventos/señales externas de workflow, pub/sub, actoresLa expresión de Inngest es más simple; Dapr es más componible.
ReintentosReintentos de función/pasoReintentos de workflow/actividad + políticas de resilienciaDapr hace de la resiliencia una política de runtime además de un comportamiento de workflow.
Dead-letter / ejecuciones fallidasEjecuciones fallidas del panel de Inngest + replayDLQ del broker + estado/reinicio/herramientas manuales de workflowInngest es más llave en mano aquí; Dapr es más nativo de infraestructura.
Control de flujoConcurrencia, throttling, prioridad, lotesEscalado de Kubernetes, concurrencia de la app, controles del broker, políticas de resiliencia, pub/sub masivoDapr puede hacerlo, pero no es un argumento del decorador. Inngest es más denso.
Coordinación con estadowait_for_event, claves de evento, estado de pasosActores + almacén de estado + workflowsLos actores de Dapr son más fuertes para identidad de larga vida/coordinación con estado.
Runtime del agenteTu agente dentro de una función de InngestDurableAgent / Dapr Agents v1.0 GADapr Agents hace explícitamente que el agente esté respaldado por workflow y sea reanudable.

Esta tabla es una guía de traducción, no una afirmación de APIs idénticas. Inngest enseña el patrón de producción con una experiencia de desarrollo compacta: disparadores, pasos, esperas, replay y control de flujo en una sola superficie de producto. Dapr implementa la misma arquitectura de producción con bloques de construcción de sistemas distribuidos: bindings, pub/sub, workflows, actores, estado, resiliencia y operaciones nativas de Kubernetes. Los conceptos se transfieren directamente; el estilo de implementación cambia. Verificado contra la descripción general de bindings de Dapr y los conceptos centrales de Dapr Agents a mayo de 2026.

Tres razones para recurrir a Dapr a escala de producción:

  • Gobernado por la CNCF, neutral respecto al proveedor por estatuto: ningún proveedor único controla la plataforma ni tu dependencia de ella.
  • Políglota con Python de primera clase. Dapr Agents es primero Python; el mismo código de agente puede correr junto a servicios escritos en JavaScript, Go, .NET, Java o PHP sin que nadie aprenda un segundo framework.
  • Escalable horizontalmente en Kubernetes por diseño. Corre en tu propio clúster, en una oferta gestionada (Diagrid Catalyst), o localmente vía dapr init. La historia de escalado es la misma arquitectura en cada entorno.

La advertencia honesta: Dapr no es una plataforma de primeros pasos. Correrlo en producción significa Kubernetes, almacén de estado, broker de pub/sub, servicio de placement, observabilidad, componentes YAML, sidecars. Eso es mucha superficie operativa cuando tu objetivo aún es aprender los patrones, que es por lo que este curso empieza en Inngest: un comando, y aparece el panel. Recurre a Dapr una vez que los patrones hayan aterrizado y la pregunta cambie a correr a escala organizacional sobre infraestructura que tú controlas.

Aprende los conceptos en Inngest y el OpenAI Agents SDK primero: ciclo de retroalimentación rápido, infraestructura mínima, foco en los patrones. Cuando alcances la escala donde la gobernanza de Kubernetes, los equipos políglotas o la neutralidad respecto al proveedor se vuelven innegociables, los mismos patrones arquitectónicos se levantan sobre Dapr con la tabla de traducción de arriba como tu clave. Los patrones se transfieren; el sustrato cambia; lo que aprendiste en este curso sigue siendo el conocimiento clave.


Lo que este curso no cubre (todavía)

El Worker que construiste satisface cuatro de los Siete Invariantes que plantea la tesis. En concreto: corre sobre un motor (Invariante 4, el SandboxAgent), contra un sistema de registro (Invariante 5, el registro de auditoría), con el mundo capaz de llamarlo (Invariante 7, los disparadores que agregaste), y con el humano como principal en una decisión con puerta (Invariante 1, parcial: el mecanismo en tiempo de ejecución está aquí, el patrón arquitectónico más amplio es más tarde). Los tres Invariantes restantes, y la arquitectura más amplia que hace de los Workers una fuerza laboral, son cursos posteriores. Una viñeta cada uno:

  • Invariante 2: cada humano necesita un delegado. Un agente personal en el borde que sostiene tu contexto, representa tu juicio e intermedia el trabajo hacia la fuerza laboral. La tesis nombra a OpenClaw como la realización actual.
  • Invariante 3: la fuerza laboral necesita un gestor. Un orquestador que asigna trabajo, hace cumplir presupuestos, audita la ejecución, expone la contratación como una capacidad invocable. La tesis nombra a Paperclip.
  • Invariante 6: la fuerza laboral es expandible bajo política. Una metacapa donde un agente autorizado genera un prompt, aprovisiona un runtime y registra un Worker nuevo, sin despertar a un humano. Claude Managed Agents es una realización.

Un solo Worker que despierta con eventos, corre de forma duradera y pone puerta a los humanos es la unidad más pequeña de la arquitectura que enseña este curso. El siguiente curso extiende ese Worker en una fuerza laboral: múltiples Workers coordinados por un gestor, expandibles bajo demanda, despertados por disparadores, gobernados por especificación. La misma base del OpenAI Agents SDK, el mismo hábito de auditoría, el mismo sistema nervioso de Inngest. La arquitectura es invariante.


Cómo volverte realmente bueno en esto

Leer este curso intensivo no te hace bueno construyendo Production Workers. Usarlo sí. Empiezas construyendo el Worker, sientes la fricción al envolverlo, y dejas que cada pieza de fricción te enseñe a qué concepto pertenece.

El mapeo para este curso:

  • "¿Por qué mi función no se dispara cuando llega el evento?" → error tipográfico en el nombre del evento o desajuste de namespace (Concepto 3). Compara la cadena del nombre del evento en tu TriggerEvent con la de inngest_client.send byte por byte.
  • "¿Por qué mi función se disparó dos veces para el mismo evento lógico?" → falta clave de idempotencia (Concepto 4). Agrega un id= al evento con una semilla determinista.
  • "¿Por qué mi función 'perdió trabajo' tras un despliegue?" → código fuera de step.run haciendo el trabajo (Concepto 7). Envuelve la E/S y los efectos secundarios en pasos con nombre.
  • "¿Por qué al cliente se le cobró dos veces?" → la llamada a Stripe estaba fuera de step.run, o el nombre del paso no era único (Conceptos 6 y 7). Mueve la llamada a un step.run con nombre; haz el nombre del paso globalmente único dentro de la función.
  • "¿Por qué OpenAI devuelve errores 429 en el pico de las 9am?" → falta throttle (Concepto 11). Agrega throttle=Throttle(limit=N, period=timedelta(minutes=1)).
  • "¿Por qué las ráfagas de un cliente dejan sin capacidad a otros clientes?" → falta concurrencia por clave (Concepto 12). Agrega una segunda Concurrency(limit=2, key="event.data.customer_id").
  • "¿Por qué mi puerta HITL se disparó en silencio durante el fin de semana?" → falta un manejador de tiempo de espera que escriba en auditoría (Concepto 15). Ramifica con approval is None y escribe la fila de auditoría explícitamente.

Construye la arquitectura una pieza a la vez. Por eso la Parte 4 son siete prompts, no uno. Construye el Worker (D0). Envuelve el agente en step.run (D1) y observa qué cambia cuando lo haces fallar deliberadamente a mitad de ejecución. Despiértalo con un evento (D2). Agrega el fan-out del cron (D3), luego el control de flujo (D4) una vez que de verdad hayas chocado con un límite de velocidad, luego la puerta de aprobación duradera (D5) cuando una acción de alto riesgo de verdad necesite un humano. Cada capa es su propio aprendizaje. Combinadas en una gran reescritura, son un muro.

La disciplina que enseña este curso (despierta con eventos, corre de forma duradera, pon puerta a los humanos, reproduce ante errores) es el invariante arquitectónico. Cualquiera que sea la plataforma que lo implemente, ese contrato de cuatro propiedades es a lo que de verdad te estás comprometiendo. Esta es la apuesta Lindy: construyes sobre las partes que han perdurado, funciones simples, SQL, un lenguaje tipado, un bus de eventos, no el wrapper de esta temporada. El producto es reemplazable; la disciplina no.


Referencia rápida

Un separador entre el curso narrativo y la referencia durante la construcción. Las secciones de abajo están pensadas para buscarse, no para leerse de arriba abajo. La esencia de una línea de cada concepto está en la hoja rápida plegada de la introducción; esta sección es el diagnóstico durante la construcción, los dos árboles de decisión y la distribución de archivos.

Árbol de decisión: elige la superficie de disparo

Cuando algo nuevo ocurre en el mundo, ¿de dónde viene el despertar?

  • Un sistema externo nos envió una solicitud HTTP. → Disparador de webhook. Configura la fuente en el panel de Inngest; reforma la carga útil vía la transformación; consume el evento resultante.
  • Un horario dice que es la hora. → Disparador cron. TriggerCron(cron="..."). Usa UTC; los crons de producción se disparan incluso cuando tu servicio está a mitad de despliegue.
  • Otra función de Inngest emitió un evento durante su ejecución. → Disparador de evento. TriggerEvent(event="ns/name.subtype"). Suscribe una o muchas funciones al mismo nombre.
  • Un usuario interactivo está esperando una respuesta inmediata. → No es un disparador de Inngest. Mantén la solicitud/respuesta en tu endpoint web normal; si la respuesta implica trabajo pesado, dispara un evento desde dentro de la solicitud y retorna de inmediato, dejando que Inngest maneje el trabajo de forma asíncrona.

Árbol de decisión: elige la primitiva de paso

Dado que una función está corriendo y necesitas hacer algo, ¿a qué llamada step.* recurres?

  • Una llamada con efectos secundarios (API, base de datos, escritura de archivo, invocación de agente).ctx.step.run("name", fn, ...). El valor por defecto. Memoizada al tener éxito, reintentada ante fallo transitorio.
  • Una llamada a OpenAI de larga duración en una plataforma serverless que factura por tiempo en vuelo.ctx.step.ai.infer(...). Descarga la inferencia a la infraestructura de Inngest para que tu proceso de función pueda liberarse.
  • Esperar una duración fija antes de continuar.ctx.step.sleep("name", timedelta(...)). Duradera; cero cómputo mientras esperas (hasta siete días en el plan gratuito, un año en planes de pago).
  • Esperar un evento externo (aprobación humana, finalización de función hermana).ctx.step.wait_for_event("name", event="...", timeout=..., if_exp=...). Duradera; se reanuda cuando llega el evento o devuelve None al agotar el tiempo de espera.
  • Cómputo determinista puro (formatear una cadena, calcular una fecha). → Solo escribe el código. No se necesita step.run; sin cargo.

Referencia rápida de ubicación de archivos

Un proyecto plano, cuatro archivos, sin anidamiento en src/:

ai-agent-nervous-system/
├── .claude/
│ └── skills/ # the four Inngest skills (installed in the Quick Win)
│ ├── inngest-setup/SKILL.md
│ ├── inngest-events/SKILL.md
│ ├── inngest-steps/SKILL.md
│ └── inngest-durable-functions/SKILL.md
├── db.py # Neon Postgres access: pooled asyncpg, load_customers, record (closed-vocabulary audit) (D0)
├── worker.py # the worker: SandboxAgent + 2 tools (D0)
├── inngest_app.py # the nervous system: Inngest functions + FastAPI host (D1-D5)
├── .env # OPENAI_API_KEY, DATABASE_URL, INNGEST_DEV=1
└── AGENTS.md # the base's rules file (read on open)

Los clientes y el registro de auditoría viven en tu base de datos Neon (aprovisionada en la Victoria rápida, sembrada en D0), no en archivos locales. El Worker (db.py, worker.py) nunca cambia después de D0. Cada capa del sistema nervioso (D1 a D5) edita inngest_app.py.

Tabla de diagnóstico, síntoma → causa raíz → concepto

SíntomaPrimer sospechosoConcepto a releer
La función nunca se dispara cuando llega el evento esperadoError tipográfico en el nombre del evento, desajuste de namespaceC3 (webhooks), C5 (fan-out)
La función se dispara dos veces para el mismo evento lógicoFalta clave de idempotenciaC4 (idempotencia)
La función "perdió trabajo" tras un despliegueCódigo fuera de step.run haciendo el trabajoC7 (memoización)
El cron no se disparó durante un despliegueSolo el servidor de desarrollo local, producción corre en la infraestructura de InngestC2 (cron)
Al cliente se le cobró dos veces por un reembolsoLlamada a Stripe fuera de step.run, o nombre de paso no únicoC6 (step.run), C7 (memoización)
Errores de límite de velocidad de OpenAI durante el pico de las 9amFalta throttleC11 (concurrencia + throttle)
Las ráfagas de un cliente dejan sin capacidad a otros clientesFalta concurrencia por claveC12 (prioridad + equidad)
La función suspendida para siempre, nunca reanudadaEl nombre del evento en wait_for_event no coincide con el evento que se envíaC8 (wait_for_event), C15 (HITL)
El tiempo de espera HITL se disparó en silencio durante el fin de semanaFalta un manejador de tiempo de espera que escriba en auditoríaD5 (puerta de reembolso duradera), C15 (HITL)
Las ejecuciones fallidas de ayer desaparecieron del panelLas ejecuciones persisten hasta que se reproducen manualmente o tras la ventana de retenciónC14 (replay)
El replay volvió a cobrar a los clientesEl replay es una ejecución nueva que vuelve a ejecutar cada paso; el cargo no tenía clave de idempotenciaC4 (idempotencia), C14 (el replay es una ejecución nueva)
La traza de la función no muestra el prompt de OpenAILa traza de pasos muestra entradas/salidas de la función pero no telemetría específica de LLM de prompt/tokenC10 (Python usa step.run; la telemetría específica de LLM necesita tu propio tracing del cliente de OpenAI; las trazas a nivel de prompt de step.ai.wrap son solo de TypeScript)

Apéndice: linaje opcional y una hoja rápida de Inngest

Este curso se sostiene solo: la Parte 4 construye el Worker desde cero, así que nada de lo de abajo es un requisito previo. Dos notas cortas de contexto.

A.1: si vienes del curso Digital FTE

El curso De agente a Digital FTE construye un Worker de atención al cliente más rico: habilidades portátiles, un sistema de registro Postgres y un servidor MCP personalizado. Si lo hiciste, ya tienes un Worker SandboxAgent en disco, y puedes saltarte el piso mínimo de D0: apunta el sistema nervioso (de D1 en adelante) a tu propio Worker en su lugar. El envoltorio es idéntico. Un extra: la puerta de reembolso duradera que construyes en D5 (step.wait_for_event) reemplaza la tabla de estado de ejecución hecha a mano de la Decisión 10 opcional de aquel curso, así que puedes eliminarla. Si no hiciste ese curso, ignora todo esto; D0 te da todo lo que necesitas.

A.2: esenciales específicos de Inngest que usa este curso

Si algo de lo de abajo te resulta desconocido, hojea la página de documentación correspondiente antes de sumergirte en la Parte 4.

  • Instanciación del cliente de Inngest. Una sola instancia inngest.Inngest(app_id=...) por proyecto Python, exportada desde un módulo e importada donde decores funciones. Inicio rápido de Python.
  • Decoración de funciones. @inngest_client.create_function(fn_id=..., trigger=...). El disparador puede ser TriggerEvent, TriggerCron, o una lista de ambos para funciones de múltiples disparadores.
  • ctx.step.run, ctx.step.sleep, ctx.step.wait_for_event, ctx.step.ai.infer. Las cuatro primitivas de paso que conforman el 90% de lo que escribirás en Python. (TypeScript tiene una quinta, step.ai.wrap, para tracing específico de LLM; los proyectos de Python usan step.run para llamadas de IA.)
  • inngest_client.send(events=[...]). Emite eventos desde cualquier parte de tu código (dentro de funciones, dentro de herramientas de agente, desde scripts de CLI). Usa un id= para idempotencia.
  • Arranque del servidor de desarrollo. npx inngest-cli@latest dev. Corre en :8288. Panel en http://127.0.0.1:8288. MCP en http://127.0.0.1:8288/mcp. Si :8288 está ocupado usa 8289+; entonces pon INNGEST_BASE_URL=http://127.0.0.1:<port> en el host para que siga, no solo la URL del MCP.

A.3: los dos cambios que de verdad son difíciles

Lo más difícil de este curso no es la sintaxis de Inngest. Es el cambio mental de solicitud a evento (Concepto 1) y de ejecución en proceso a ejecución duradera (Concepto 6). La sintaxis es mecánica una vez que esos dos aterrizan. Relee los Conceptos 1 y 6 primero si cualquier otra cosa se siente más difícil de lo que debería.