Skip to main content

Dale a tu agente de IA un sistema nervioso: curso intensivo de 90 minutos

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

Has construido un agente que funciona. El problema: solo funciona mientras lo vigilas. Abres Claude Code u OpenCode, escribes y responde. Te alejas y se detiene. Cerrar esa brecha, entre un agente que operas y un worker que corre por su cuenta, es de lo que trata este curso.

Lo que cierra la brecha 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, skills para las tareas que ya conoce. Lo que le falta es un sistema nervioso.

Piensa en tu propio cuerpo. Tu cerebro piensa y tus músculos actúan. Pero un segundo sistema corre por debajo, sin ti: tus latidos, tus reflejos, las señales que te mantienen vivo mientras duermes. Dejas de prestar atención y tu corazón sigue latiendo. Un agente no tiene nada parecido. Así que en cuanto dejas de conducirlo, se detiene.

Un sistema nervioso cierra el ciclo por su cuenta, sin un humano en cada turno. Percibe el mundo y despierta al agente cuando algo ocurre. Reacciona por reflejo cuando un paso falla, y mantiene su lugar durante horas mientras espera a una persona o a una API lenta. Mantiene al agente estable cuando llegan quinientas solicitudes de golpe. Esa es la diferencia entre un agente que operas y un FTE que corre por su cuenta. Le agregas este sistema nervioso a tu agente. No reescribes el agente. Esa es la única idea sobre la que se construye este curso.

📚 Material de apoyo

Abrir la presentación completa

Ver presentación completa — Sistema nervioso del agente de IA


Esta herramienta tiene un nombre técnico: un motor de ejecución duradera. Usamos uno llamado Inngest. Los mismos patrones funcionan en Temporal, Restate y Dapr Agents. Y esto no es solo una imagen didáctica: Day AI, un CRM para empresas nativas de IA, llama a Inngest "el sistema nervioso" de su producto. El nivel gratuito Hobby de Inngest es el lugar más fácil para empezar: sin tarjeta de crédito, un dev server de un solo comando y un panel que puedes mirar mientras construyes.

Antes que nada, aquí está todo el montaje en una sola imagen:

  1.  ocurre un EVENTO   (p. ej. un cliente envía un correo)
|
v
2. el MOTOR DE INNGEST lo captura
(esto NO lo construyes tú. corre tu agente por ti:
reintentos, esperas, recuerda cada paso, muestra un panel)
|
| llega a tu código por un fino cable web (FastAPI)
v
3. TU AGENTE corre
(la única parte que escribes. piensa y actúa.)

Ese es el modelo entero: dos programas. El motor (no lo escribes tú) captura eventos y corre tu agente (ese sí lo escribes), llegando a él por un fino cable web, que es la única razón por la que un servidor web (FastAPI) aparece en este curso. Arrancas ambos en la Quick Win y miras al motor conducir tu agente.

El ejemplo es pequeño a propósito: un agente de soporte al cliente que busca unos pocos clientes de muestra, redacta una respuesta y emite un reembolso solo después de que un humano lo aprueba. El agente no es la parte difícil, así que lo mantenemos pequeño y dedicamos el esfuerzo al sistema nervioso a su alrededor. Lo construyes aquí desde cero. Retoma donde lo dejó el curso anterior de Digital FTE, aunque D0 monta un worker mínimo desde cero si te lo saltaste. Es Python primero, sobre inngest-py: diriges a tu agente general en lenguaje natural y él escribe el código.

Así está construido el curso, para que lo leas de la forma correcta. La construcción es la columna vertebral. Montas el entorno una sola vez en la Quick Win, y luego la Parte 4 construye el worker entero en siete prompts cortos, una capa del sistema nervioso a la vez. Ese es el camino, y hacerlo es como aterriza el modelo. Los quince conceptos de las Partes 1 a 3 son la referencia de la que se nutre la construcción: una idea cada uno, el "por qué" bajo una capa que estás a punto de agregar. Hay dos buenas formas de avanzar. Lee primero las Partes 1 a 3 si te gusta la idea antes del teclado. O ve directo a la Quick Win y a la Parte 4, y regresa a un concepto en cuanto una capa te haga preguntar "pero ¿por qué funciona así?". De cualquier modo, la Parte 4 es donde construyes.

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 WORKER DE IA contiene el sistema nervioso (Inngest, la capa autónoma) envolviendo al agente. El sistema nervioso tiene tres capas numeradas: 1 Sentidos (triggers), 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) contiene el OpenAI Agents SDK, Skills, 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.

El agente nunca importa el sistema nervioso, así que puedes intercambiar Inngest por Temporal o Restate y dejar el agente intacto.

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

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

  1. Cada paso cuesta dinero real. Un reintento ingenuo tras una caída vuelve a pagar por pasos que ya tuvieron éxito; la memoización de pasos (Concepto 7) paga una sola vez.
  2. Los flujos de trabajo componen el fallo. Un agente de seis pasos con 95% de fiabilidad por paso tiene un 26% de probabilidad de fallar en algún punto. La memoización de pasos más reintentos dirigidos elevan la fiabilidad total a ~99,7%.
  3. Los efectos secundarios son del mundo real. Los agentes envían correos a clientes, cobran tarjetas, publican en Slack. La memoización de pasos más las claves de idempotencia a nivel de proveedor las vuelven seguras.
  4. Los agentes necesitan aprobación humana en momentos de alto riesgo. Sin step.wait_for_event (Concepto 15), construyes tú mismo una cola de aprobación: tabla de base de datos, polling, manejo de timeouts, traza de auditoría. Eso es un proyecto, no una función.

Day AI, el CRM para empresas nativas de IA, corre su producto sobre cada primitiva que enseña este curso: flujos de trabajo de LLM duraderos, coordinación con wait-for-event, replay ante fallos, debounce más throttle más concurrencia, y equidad multiinquilino. Dos de sus ingenieros fundadores recurrieron a la misma imagen del sistema nervioso por su cuenta. Es lenguaje de producción, no etiquetado de plan de estudios.

Dónde encaja este curso en la tesis de Agent Factory

La tesis de Agent Factory describe Siete Invariantes que todo sistema de agentes en producción debe satisfacer. El worker que construyes aquí satisface el Invariante 4 (un motor) y el Invariante 5 (un sistema de registro, aquí una pequeña traza 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 triggers (programaciones, webhooks, llamadas API entrantes, eventos de otros workers) despiertan al worker. Inngest es una realización.
  • Invariante 1, en parte: el humano es el principal. Los gates de aprobación son donde la intención autorizada vuelve a entrar en el runtime. step.wait_for_event es la expresión más limpia en cualquier plataforma: el agente se suspende, un humano emite el evento esperado, el agente se reanuda.
  • La ejecución duradera como invariante implícito de la tesis. La auditoría responde "¿qué pasó?"; la durabilidad responde "hazlo de nuevo desde donde se rompió". Reproducible, reintentable, reanudable tras un fallo.

Los 15 conceptos, de un vistazo. Se asignan a los tres trabajos que hace un sistema nervioso: los sentidos (los triggers 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 sano bajo carga). Esta es la versión de primera pasada, concepto más una pista de una línea. Cuando algo se rompa durante una construcción, la Referencia rápida al 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 el mapa completo)
#ConceptoPista de una línea
Sentidos (Triggers)cómo el mundo llega al worker
1Eventos vs. solicitudesUna solicitud es síncrona y alguien espera; un evento es asíncrono y el mundo ya siguió adelante.
2Triggers de cronUna programación despierta a la función. Una línea: TriggerCron(cron="0 9 * * *").
3Triggers de webhookUn payload HTTP entrante se convierte en un evento con nombre; tu función reacciona al nombre.
4Idempotencia y semántica de eventosLos IDs de evento y los nombres de paso hacen que un evento duplicado (o un reintento) no tenga efecto.
5Fan-out y delegación a subagentesUn evento, N funciones suscritas; o un padre que dispara 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 caerse entre pasos y reanudarse.
7Memoización, el mecanismo de fondoLos pasos completados devuelven su salida guardada en lugar de volver a ejecutarse.
8step.sleep y step.wait_for_eventAmbos suspenden la función de forma duradera, por una duración 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 y recuperacióncontrol de flujo bajo carga, recuperación y el gate humano
11Concurrencia y throttlingconcurrency limita las ejecuciones activas; throttle limita los arranques por segundo.
12Prioridad y equidadLa prioridad ordena la cola; la concurrencia por clave da a cada inquilino una porción justa.
13BatchingAcumula eventos en una sola llamada de función en lote para trabajo masivo barato.
14Replay y cancelación masivaReproduce ejecuciones fallidas con código nuevo; cancela en masa ejecuciones que ya no quieres.
15Gates HITL con step.wait_for_eventLa función se suspende hasta que un humano aprueba, luego se reanuda con la decisión.

Requisitos previos. Este curso supone que hiciste De agente a Digital FTE. Si lo hiciste, ya cumples todo lo de abajo y tienes un worker que vale la pena envolver: el sistema nervioso de la Parte 4 apunta justo a él, y te saltas el montaje desde cero en D0. Si no lo hiciste, haz primero ese curso, o sigue leyendo de todos modos: D0 construye un worker mínimo desde cero para que el resto del curso se sostenga por sí solo. De cualquier modo, necesitas cuatro cosas.

  1. Sabes conducir un agente general. Claude Code u OpenCode, instalado y autenticado. El modo plan, los archivos de reglas, el flujo de leer primero y luego escribir: si ese ritmo te es familiar, estás calibrado. El Curso intensivo de programación agéntica lo cubre si no.
  2. Una OPENAI_API_KEY (u otra clave de modelo que tu agente general pueda usar) y una cuenta de Neon para el Postgres que será el sistema de registro del worker. El worker corre un modelo real y lee y escribe sus clientes y su traza de auditoría en Neon. Neon es gratis (sin tarjeta) y lo autorizas con un clic en el navegador durante el montaje; regístrate en neon.com en cerca de un minuto si no tienes cuenta. El propio dev server de Inngest no necesita ninguna cuenta.
  3. Node.js 20+ disponible, aunque el worker sea de Python. El dev server de Inngest se distribuye como una CLI de Node (npx inngest-cli@latest dev).
  4. Un modelo mental funcional de "orientado a eventos" vs. "solicitud/respuesta". Si "el mundo dispara un evento y cero, una o muchas funciones reaccionan a él" te suena familiar, estás calibrado. Si no, el Concepto 1 te da la forma.
Cómo leer esta página en una primera pasada

Las dos pasadas. La primera pasada te mete en la cabeza el modelo del sistema nervioso, sus tres capas; la segunda, con las manos en el teclado en la Parte 4, es donde construyes. Si prefieres construir primero y que el modelo se forme sobre la marcha, eso también funciona: empieza en la Quick Win, ejecuta la Parte 4 y trata cada concepto como la referencia que abres cuando una capa plantea un "por qué". Expande cualquier cosa etiquetada como "Listo cuando" o "Qué observar": comportamiento ejecutable contra el que comprobar tus predicciones. En la Parte 4 puedes hojear los snippets que cargan el peso 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 de extensión opcionales. 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. Cada término se define en contexto donde aparece por primera vez.

Vigencia

Vigente a mayo de 2026. Toda la construcción de la Parte 4 se ejecutó de extremo a extremo contra un dev server de Inngest en vivo y un modelo real con inngest 0.5.18, openai-agents 0.17.x (construido y reverificado en 0.17.3 y 0.17.4), fastapi 0.136.3, Python 3.12 y la CLI de Inngest. Cada snippet de la Parte 4 proviene de esa construcción funcional, no escrito de memoria. La arquitectura que enseña este curso no cambia cuando cambia el SDK; el SDK es la interfaz de este año hacia ella. El único lugar donde un cambio menor de openai-agents puede morder es el detalle de reanudación de D5 (cómo maneja la serialización del estado de ejecución un contexto personalizado), así que esa Decisión enlaza la documentación en vivo directamente. Si una página de documentación en vivo y esta página alguna vez discrepan en un detalle de sintaxis, gana la documentación: fija tus versiones y consulta el inicio rápido de Inngest para Python y la documentación del OpenAI Agents SDK cuando construyas.

Elige tu herramienta y la página te sigue

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


Quick win de quince minutos: monta la base y observa el reflejo

Antes de los 15 conceptos que explican por qué funciona esto, monta el entorno en el que corre el curso y observa cómo una tarea sobrevive a una caída. Este montaje lo haces una vez; la Parte 4 construye el worker real sobre la misma base. Al final tendrás:

  • la base abierta en tu agente general, con sus skills y herramientas montadas por ti,
  • una base de datos Neon nueva con dos tablas (customers y audit_log) que tu agente creó,
  • un worker diminuto corriendo, con un panel donde puedes mirarlo,
  • una ejecución que viste irse a dormir mientras esperaba, quemando cero cómputo todo ese tiempo,
  • una ejecución que rompiste a propósito, y luego viste cómo el sistema reintentaba: conservó el trabajo que ya había terminado y volvió a ejecutar solo la parte que se rompió,
  • y esa misma función con un agente real escribiendo el saludo dentro del paso duradero, así que terminas habiendo visto correr a un worker de IA, no solo a un temporizador.

Esos dos últimos puntos son lo central: el reintento es el reflejo del que trata todo este curso, y el agente que corre dentro de él es la promesa a la que ese reflejo sirve. Es una sola sentada, no la construcción completa de la Parte 4, así que hazlo y luego vuelve por los conceptos.

Ahora arrancas los dos programas de la apertura: tu worker (tu código) y el dev server de Inngest (el motor que corre a su lado, con su panel en http://127.0.0.1:8288, donde /runs lista cada ejecución). Se conectan a través de una pequeña capa web siempre activa, FastAPI, la puerta a la que el dev server toca para iniciar una ejecución. Todo el ciclo en una línea: llega un evento, el dev server alcanza a tu worker por esa puerta, tu función duradera corre un paso a la vez, y cada paso queda registrado en el panel. Tu agente general escribe y arranca ambos por ti; tu trabajo es mirar.

Importa un límite más, el mismo que trazó el curso de Digital FTE. Tu worker guarda sus clientes y su registro de lo que hizo en una base de datos Neon, y esa base de datos se toca de dos formas distintas. Mientras construyes, tu agente general entra a Neon por ti, en lenguaje natural, para crear las tablas y revisar las filas. Mientras el worker corre, habla con la misma base de datos por una conexión ordinaria propia. La herramienta de tiempo de construcción nunca queda cableada al worker en ejecución; la propia documentación de Neon es tajante en que es para construir e inspeccionar, no para producción. Neon es gratis con un clic; el dev server de Inngest no necesita ninguna cuenta.

Obtén la base y ábrela

Descarga la base y abre la carpeta en tu agente general. El agente hace el montaje él mismo, a partir de los prompts de abajo. Esto lo montas una vez: ai-agent-nervous-system/ es tu carpeta para todo el curso, tanto la Quick Win como la Parte 4. Nunca vuelves a descargar ni a descomprimir.

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

cd ai-agent-nervous-system
claude
cd ai-agent-nervous-system
opencode

Esta base supone un agente general capaz (Claude Code, u OpenCode corriendo 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 seguir.

Prepara la base (~3 min)

La base trae sus reglas en AGENTS.md y su cableado de MCP; las Skills, tu clave y la autorización de Neon vienen a continuación. Haz que tu agente se monte solo. Pega esto:

Lee AGENTS.md y luego deja esta base lista: instala las Skills que enumera 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.

Observa que: el agente instala las cuatro Skills de Inngest y la Skill neon-postgres (ves las ejecuciones de instalación y las confirmaciones Installed), crea .env, y luego te pide 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 en la misma pantalla de autorización. INNGEST_DEV=1 ya está en .env, así que el SDK corre en modo dev local sin clave de firma. Cuando la instalación y el cableado estén listos, el agente te dice que arranques el dev server (siguiente paso) y luego lo reinicies, porque las nuevas Skills y el MCP inngest-dev no se cargan a mitad de sesión.

Listo cuando: las Skills están instaladas, .env contiene tu clave, Context7 es alcanzable y Neon está autorizado. El MCP inngest-dev entra en línea una vez que el dev server está corriendo, que es el siguiente paso.

Arranca el dev server 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 dev server en ejecución al que envía eventos y observa. Así que antes de construir nada, levanta ambos y confirma que están en vivo.

Arranca el dev server 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 dev server expone su endpoint MCP en /mcp. Ahora reinicia tu agente general (sal y relánzalo en la carpeta ai-agent-nervous-system) para que tanto las Skills recién instaladas como el MCP inngest-dev se carguen. Luego pega esto:

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

Observa que: dos listas reales. Las herramientas de Neon (crear un proyecto, ejecutar SQL, describir tablas, obtener una cadena de conexión y similares) 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 dev server en ejecución. Todo lo de abajo se apoya en ambas.

Gate abierto: 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 dev server no está corriendo (arráncalo), o te saltaste el reinicio (sal, relanza en esta carpeta, vuelve a preguntar).

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 sus clientes y escribe su traza de auditoría aquí. Pega esto:

Pega esto a tu agente general. Planifica primero; ejecuta tras 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 lo ejecute yo.

Observa que: el agente llama a las herramientas MCP de Neon para crear el proyecto y las dos tablas (ves esas llamadas a herramientas, no SQL que escribiste tú), y luego escribe DATABASE_URL en .env. Esa cadena es el traspaso: 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 contiene un 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 corra. (Una tabla es solo una hoja de cálculo: cada fila una cosa, cada columna un detalle.)

Construye la primera función duradera y condúcela desde tu agente (~3 min)

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

Usando las Skills 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 dev local, y arranca el host en el puerto 8000 con auto-reload activado, para que los cambios que haga después se recojan sin un reinicio manual.

La forma que escribe, para que la reconozcas al verla: la función es un simple async def, las dos llamadas step.run envuelven trabajo que debe memoizarse, y el step.sleep entre ellas suspende la ejecución de forma duradera. El proceso puede caerse, reiniciarse o redesplegarse durante ese sleep, y la ejecución igual se reanuda en la siguiente línea cuando el temporizador dispara. 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 calladamente cae por defecto en Cloud y tu función nunca se registra localmente.

Listo cuando: el host de FastAPI (la puerta de antes) está corriendo en el puerto 8000, y el dev server (ya corriendo del paso anterior) lo autodescubrió. Pídele a tu agente que lo confirme con la herramienta list_functions de inngest-dev (o abre http://127.0.0.1:8288, haz clic en Functions y mira greet-customer en la lista). Desde aquí envías eventos desde tu agente y miras las ejecuciones en el panel.

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

Envía el evento de disparo desde tu agente. Pega esto:

Envía un evento demo/greet con name Sara usando la herramienta send_event de inngest-dev.

(¿Prefieres el panel? En http://127.0.0.1:8288, haz clic en Events, luego en Send event, pega el payload de abajo y haz clic en Send. De cualquier modo arranca la misma ejecución.)

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

Ahora observa el sleep duradero, y tienes cerca de quince segundos para captarlo en vivo. Dos formas, elige una:

  • Deja que el agente haga polling (la forma nativa del agente): "Haz polling de get_run_status en esa ejecución hasta que termine." A mitad del sleep el agente reporta la ejecución como Running sin hora de fin aún, tu terminal del host inactivo todo el rato; luego cambia a Completed con el dict de salida y una brecha de inicio a fin de unos quince segundos. Esa brecha es el sleep.
  • Mira el panel: abre http://127.0.0.1:8288Runs → la ejecución más nueva, de inmediato. El primer paso está hecho y el paso sleep muestra Sleeping con una hora de reanudación; tras quince segundos se reanuda por sí solo y cambia a Completed, con el dict devuelto en el panel Output.

De cualquier modo, nada en tu código corre durante esos quince segundos: el dev server retiene la hora de reanudación y el host se queda inactivo. Ese es el punto: una espera duradera cuesta cero cómputo. (Abre la ejecución después de que terminó y solo ves Completed con la salida, el sleep en vivo ya pasó; reenvía y mira más rápido, o deja que el agente haga polling.)

Rompe un paso y observa cómo el reintento se salta el trabajo que ya hizo (la recompensa)

Ahora haz que un paso falle a propósito, para que puedas ver 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. Mantén todo lo demás igual.

Envía de nuevo el mismo evento demo/greet, luego lee la traza por paso de la ejecución fallida en el panel (Runs → la más nueva). Aquí está la recompensa, y está en esta única ejecución fallida: el paso del saludo muestra un intento completado, y el paso de la despedida muestra varios Attempts, cada uno reintentado con backoff (Inngest reintenta varias veces por defecto) antes de que la ejecución aterrice en Failed. Detente en lo que significa ese conteo de intentos: el paso del saludo completado se paga una vez, no una vez por reintento. Esa es ejecución duradera que puedes ver con tus propios ojos. Por qué el paso completado devuelve al instante en vez de volver a correr es el mecanismo que conocerás en el Concepto 7; por ahora, solo míralo suceder.

Dos cosas que esperar cuando ejecutes esto:

  • La prueba por paso está en el panel, no en el agente. Tu agente dispara el evento y puede reportar el estado a nivel de ejecución, pero get_run_status del MCP del dev server devuelve el resumen de la ejecución con steps: null; no expande los intentos por paso. Los conteos de intentos que son la prueba de la memoización (el saludo en uno, la despedida subiendo) viven en la vista Runs del panel. Este es el único punto de la Quick Win donde recurres al navegador, no al agente.
  • Llegar a Failed toma unos minutos. Con los reintentos por defecto y el backoff exponencial, la ejecución sigue reintentando el paso de despedida durante varios minutos (una ejecución real tomó cerca de cuatro y medio) antes de cambiar a Failed. No tienes que esperar hasta el final: la prueba de la memoización se muestra desde el primer reintento en adelante, el saludo quedándose en un intento mientras la despedida acumula más. Observa un par de intentos y sigue adelante.

(Esta construcción del dev server tampoco muestra una insignia separada de "memoizado". La memoización es el conteo de intentos: el paso completado quedándose en un intento mientras el paso roto sube es exactamente lo que "devuelto desde la memoización, no vuelto a correr" se ve aquí.)

Ahora arréglalo:

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

El host se recarga solo (eso es lo que te compró --reload; si te lo saltaste, reinicia el host a mano). Envía un evento demo/greet nuevo y toda la función ahora corre limpia hasta Completed sobre el código arreglado. Una cosa sobre la recuperación que confunde a la gente. El botón Rerun del panel inicia una ejecución nueva desde el principio con tu código actual, cada paso volviéndose a ejecutar desde cero. Esa es la herramienta correcta para la recuperación ante incidentes: un mal despliegue rompió un lote de ejecuciones, así que 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 ver dentro de la ejecución fallida, donde el paso completado se quedó en su lugar.

Conviértelo en un worker de IA real (el puente a la Parte 4)

Hasta ahora la función solo hace malabares con cadenas, y eso fue a propósito: la durabilidad es más fácil de ver sin nada más en medio. Ahora haz que el saludo venga de un agente real, para que veas el mismo sistema nervioso llevar una llamada de IA real. Un prompt cambia el saludo fijo por un agente diminuto; el sleep, la durabilidad y el panel se quedan exactamente como están. Pega esto:

Reemplaza el saludo fijo por una llamada de una línea a un agente hello-world mínimo construido sobre el OpenAI Agents SDK (solo escribe el saludo), aún dentro del mismo step.run. Mantén el step.sleep y la despedida sin cambios. Luego dispara un evento demo/greet y muéstrame la ejecución.

Lo único que cambió es lo que llena el paso del saludo: en lugar de un f-string, un modelo lo escribe. Y como esa llamada se sitúa dentro del mismo step.run que ya probaste duradero, queda memoizada y a salvo de caídas de gratis, sin cableado nuevo. Observa la ejecución como lo hiciste antes (polling desde el agente, o ábrela en el panel): la misma traza de tres pasos y el mismo sleep a cero cómputo, salvo que la salida del primer paso ahora vino de un agente. Tu OPENAI_API_KEY ya está en .env desde el paso de preparación, así que no hay nada nuevo que montar.

Listo cuando: una ejecución demo/greet se completa y el saludo en la salida vino del agente, no de una cadena fija. Detente en lo que estás mirando, porque es el curso entero en una frase: un agente de IA, despertado por un evento, corriendo de forma duradera dentro de un sistema nervioso, sobreviviendo a una caída. La Parte 4 cambia este agente hello-world por un worker de soporte al cliente real y lo envuelve en el sistema nervioso completo (un trigger de evento real, un cron que hace fan-out, control de flujo, un gate de aprobación humana), pero la forma que tienes en pantalla ahora mismo es la forma.


Acabas de montar todo el entorno del curso y ver el sistema nervioso funcionar con tus propios ojos: las Skills están instaladas, tu almacén Neon está aprovisionado con DATABASE_URL en .env, el MCP del dev server está en vivo, y corriste una función duradera, viste un paso dormir sin consumir cómputo, rompiste un paso y viste el reintento automático devolver el paso completado desde la memoización mientras solo el roto volvía a correr, y luego viste a un agente real generar el saludo dentro de ese mismo paso duradero. 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 el gate de aprobación humana que convierte "el agente podría arruinar esto" en "el agente redacta, un humano aprueba, la acción se emite".

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

  1. El dev server no puede alcanzar el host de la función: 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 dev server, y corre un host contra un dev server. (Una causa sutil: si :8288 estaba ocupado y el dev server arrancó en 8289+, reapuntar la URL del MCP inngest-dev no basta; el host sigue hablándole a :8288. Pon INNGEST_BASE_URL=http://127.0.0.1:<port> en el host para que siga al dev server al nuevo puerto.)

Si te topas con cualquiera de estos, la jugada de recuperación universal también sirve aquí: "Algo no funcionó. Lee el error, dime en lenguaje natural qué ves y propón un arreglo que pueda aprobar."

Lo que construiste y dónde crece

El entorno está montado: la base está abierta, las Skills están instaladas, los tres servidores MCP están cableados (Neon, Context7, inngest-dev), tu almacén Neon tiene sus tablas customers y audit_log con DATABASE_URL en .env, y el dev server está corriendo. También viste la única idea sobre la que se sostiene todo el curso, el reflejo de la ejecución duradera, con tus propios ojos, y viste a un agente real correr dentro de él. La Parte 4 cambia ese agente hello-world por el worker de soporte al cliente, sobre esta misma base, en esta misma carpeta: lee esos clientes y escribe esas filas de auditoría, luego envuelve todo en el sistema nervioso completo, un trigger de evento real, un cron diario que hace fan-out, control de flujo, y el gate duradero 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 Neon. Si esta Quick Win funcionó, los conceptos por delante explican por qué cada pieza tiene esta forma.


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

De aquí en adelante, las Partes 1 a 3 son el estante de referencia detrás de la construcción: quince conceptos, una idea cada uno, agrupados por los tres trabajos que hace un sistema nervioso. Puedes leerlos de corrido, o entrar a uno cuando una capa de la Parte 4 te haga preguntar por qué funciona. Este primer grupo son los sentidos.

Un agente de IA al que llamas a mano corre cuando lo llamas. Un Worker de IA real tiene sentidos: corre cuando el mundo lo alcanza. Un cliente envía un correo, llega un webhook, un cron dispara a las 09:00 a diario, otro worker le pasa trabajo. Cada una de estas es una señal que entra, y un trigger es cómo el agente la siente. Los cinco conceptos de la Parte 1 son esos sentidos: el modelo mental orientado a 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 dejan que una señal despierte a muchos workers.

Concepto 1: eventos vs. solicitudes, el cambio mental hacia lo duradero

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

Una solicitud es una conversación síncrona. Alguien llama; tú atiendes; devuelves; ellos continúan. Una conexión queda abierta; un humano o un servicio está esperando. Si te caes, quien llamó recibe un error. Un agente con el que chateas en el prompt es una solicitud: escribiste, te respondió en streaming, la conversación perteneció a tu sesión de terminal.

Un evento es un mensaje asíncrono. Algo pasó en el mundo (un cliente se registró, llegó un correo, un pago se acreditó), y el originador emite un registro con nombre de ese hecho. Cero, una o muchas funciones reaccionan al evento de forma independiente. Ninguna conexión queda abierta. El originador no sabe quién está escuchando, no espera resultados y no se bloquea. El mundo 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 síncrono, bloqueante y frágil: un productor dispara una solicitud y espera sobre una conexión abierta mientras el consumidor corre unos ocho segundos; una caída pierde el trabajo, y absorber cincuenta solicitudes por minuto necesita unos siete manejadores en paralelo. El modelo de evento (abajo, verde) es asíncrono, duradero y aislado: un productor dispara un evento y retorna en unos diez milisegundos hacia un registro de eventos duradero, que hace fan-out a consumidores independientes (un agente, un contador de analítica, un detector de VIP); cae un consumidor y el evento espera en el registro y reintenta. El evento es la señal; una vez almacenado, el worker reacciona a su propio tiempo.

Una solicitud hace esperar al productor; un evento lo libera, y el evento almacenado sobrevive a una caída.

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

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

Predice. Tu worker de soporte al cliente tarda 8 segundos en responder a un correo: tres segundos para el razonamiento del agente, cuatro segundos para dos llamadas a herramientas MCP, un segundo para la escritura en la base de datos. En carga pico recibes 50 correos por minuto. Si usas el modelo de solicitud (el parser del correo se bloquea hasta que el agente termina), ¿cuántas conexiones HTTP en paralelo a tu parser de correo implica eso? Si usas el modelo de evento (el parser dispara un evento y retorna de inmediato), ¿cuántas? Confianza 1-5.

La respuesta: el modelo de solicitud necesita unos 7 parsers concurrentes (50/min × 8 segundos es ~6,7 manejadores en paralelo, más holgura). El modelo de evento necesita un parser. Dispara el evento y retorna en ~10 ms, la cola de eventos absorbe el pico de 50/min, y las funciones de Inngest consumen la cola a la concurrencia que permitas.

Esa brecha es todo el punto. El evento se vuelve un límite duradero entre "lo que pasó en el mundo" y "lo que el worker hace al respecto", y todo lo bueno se sigue de ese único movimiento: el productor nunca espera, un consumidor caído reintenta desde el evento almacenado, y consumidores nuevos se enganchan sin tocar al productor. Los eventos son cómo dejas de ser dueño del tiempo 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: triggers de cron, trabajo que corre porque pasó el tiempo

El trigger más simple es el reloj. Muchas cosas que hace un Worker de IA no son reacciones a eventos externos; son trabajo programado: reportes diarios de salud, limpiezas semanales, recálculos por hora. El trigger de 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
events = [
inngest.Event(name="customer/health_check.requested", data={"customer_id": cid})
for cid in customers
]
await ctx.step.send_event("fan-out", events)

return {"customers_scheduled": len(customers)}

Tres cosas que notar:

  • La programación es solo sintaxis cron estándar. 0 9 * * * es las 09:00 UTC a diario; */15 * * * * es cada 15 minutos; 0 9 * * 1 es los lunes a las 09:00. Inngest evalúa el cron en UTC por defecto; si necesitas otra zona horaria, antepones un prefijo a la propia cadena cron (por ejemplo TZ=Europe/Paris 0 12 * * 5), en lugar de pasar un argumento aparte.

  • La función sigue usando los mismos pasos duraderos. Disparada por cron o por evento, la forma de la función es idéntica: ctx.step.run para efectos secundarios, ctx.step.send_event para hacer fan-out. La durabilidad funciona igual. El control de flujo funciona igual. El trigger es solo cómo arranca la función.

  • La salida del cron es una ejecución normal de función de Inngest. Aparece en el panel, tiene un ID de ejecución, tiene una traza, admite replay. Si tu ejecución del cron del lunes por la mañana falla en el paso 3, la del martes corre normal y el fallo del lunes queda disponible para replay después de que arregles el bug.

¿Qué pasa si tu servicio está caído cuando dispara el cron? Esta es la pregunta que separa un programador duradero de uno frágil. Las ejecuciones de cron de Inngest quedan registradas de forma duradera en el instante en que dispara la programación. Si el endpoint de tu función es inalcanzable, Inngest reintenta con backoff hasta tener éxito o tocar el techo de reintentos. El cron disparado a las 09:00 no se "pierde" porque tu despliegue estaba en curso a las 09:00; la ejecución espera, terminas tu despliegue, la ejecución se completa. Los triggers de cron en desarrollo tienen una particularidad que vale la pena conocer: el dev server local solo dispara crons mientras está corriendo. Producción los corre 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 de cron tarda 45 minutos en correr y está programada cada 15 minutos, tres instancias concurrentes estarán corriendo en todo momento. (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 pruebas.

Respuestas: (a) Depende de la política de concurrencia: por defecto Inngest hará cola con las ejecuciones que se solapan; 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 a lo largo de horas para suavizar la carga". (c) Verdadero: el panel de Inngest deja invocar cualquier función a demanda para pruebas, sin importar su trigger.

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, test it now instead of waiting for
Monday: trigger it on demand from the Inngest dev dashboard (the
Invoke button), since the dev server only fires crons while it is
running. Confirm the audit query is correct by running the SQL
directly against the database and checking the rows it returns;
grep_docs can confirm your step.run pattern matches Inngest's
examples, but only running the query proves the SQL itself.

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

El primer trigger fue el reloj. El segundo es HTTP: algo fuera de tu sistema (Stripe, tu proveedor de correo, un formulario en tu sitio, un evento de GitHub) quiere alcanzar a tu worker.

Sé preciso sobre cuál parte es la difícil, porque no es la que pensarías. Recibir el POST es fácil: un framework web como FastAPI te da @app.post(...) en tres líneas. La parte difícil es todo lo que viene después de que aterriza el POST: encolar la llamada, reintentarla ante un fallo, sobrevivir a una caída a mitad del trabajo, negarse a procesar dos veces una reentrega, correr el agente, sostener una aprobación de cuatro horas, reproducir cualquier ejecución desde un panel. La puerta es barata; la cocina detrás de ella es el trabajo, y esa cocina es Inngest.

Así que la ruta se mantiene diminuta. Todo su trabajo es recibir el POST, entregarle el evento a Inngest y responder 200 rápido. El trabajo duradero corre en la función de Inngest detrás de ella. Si en cambio hicieras ese trabajo dentro del manejador de la solicitud, te toparías con los bugs clásicos de webhook: el emisor agota el tiempo de espera y reenvía mientras sigues trabajando, un reinicio pierde el trabajo, una reentrega reembolsa al cliente dos veces. (La opción alojada de Inngest puede incluso acuñar una URL pública inn.gs/e/... para que te saltes escribir la ruta del todo.)

Ahora la parte que confunde a todos. Tu app termina con dos puertas, y miran en direcciones opuestas:

  DOOR 1: the webhook door  (you write it, or use the hosted URL)
Stripe knocks here with DATA -> it just calls send() and is done

DOOR 2: /api/inngest (auto-made by inngest.fast_api.serve)
the ENGINE knocks here to RUN YOUR CODE, one step at a time
it speaks Inngest's own protocol, so a raw Stripe POST here is rejected

Estas dos nunca se hablan directamente. Se conectan solo a través del evento: la Puerta 1 deja caer un evento, el motor lo recoge y vuelve por la Puerta 2 a correr tu función. Crear automáticamente la Puerta 2 (lo que la Quick Win ya hizo) no hace nada por la Puerta 1; esa es la que aún escribes.

Entonces, ¿qué llama en realidad la puerta del webhook? Solo a send(). La ruta entera es así de pequeña:

@app.post("/webhooks/stripe")
async def stripe_webhook(request: fastapi.Request):
payload = await request.json()
# verify the signature, reshape Stripe's envelope, then hand it off:
await inngest_client.send(
inngest.Event(name="stripe/charge.refund.failed", data=reshape(payload)),
)
return {"ok": True} # ack Stripe in milliseconds

Ese send() deja caer el evento en el stream de Inngest y la ruta queda terminada. No llama a tu función, y no llama a /api/inngest. Inngest se encarga de esa mitad: hace coincidir el nombre del evento con on_refund_failed y vuelve por la Puerta 2 a correr los pasos de la función. De extremo a extremo:

Stripe → Puerta 1 (webhook) → send() → Inngest → Puerta 2 (/api/inngest) → tu función

@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"]

# Find the support ticket this refund belongs to
ticket = await ctx.step.run(
"find-ticket-for-refund", lookup_ticket_by_charge, charge_id,
)

# Hand the support worker the full context.
# step.run takes (step_id, handler, *args): pass args positionally, not as kwargs.
await ctx.step.run(
"notify-support-agent",
notify_support_agent_of_refund_failure,
ticket["id"], charge_id,
)

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

Esa es la función detrás de la puerta: Inngest hizo coincidir el evento con ella y la corrió, buscando el ticket y notificando al worker de soporte, con la cola, los reintentos y la idempotencia manejados por ti. El trabajo de webhook es casi siempre asíncrono así: la función corre en segundo plano después del ack rápido, nunca durante la solicitud.

Dos patrones que merecen un nombre:

  • Webhooks de JSON genérico. El emisor no tiene que ser un proveedor famoso. Apunta cualquier servicio que pueda hacer POST de JSON al mismo tipo de URL y elige el nombre del evento tú mismo. El estilo vendor/event.subtype es solo convención, pero el panel agrupa los eventos de forma limpia cuando lo sigues.
  • Transforms de webhook. Los payloads de los proveedores son grandes y anidados, y un mismo proveedor suele enviar muchos tipos de evento a una sola URL. Un transform es una pequeña función de reformateo que corre en los servidores de Inngest en el instante en que llega el payload, antes de que se vuelva un evento. (Se escribe en JavaScript incluso cuando tu worker es Python, porque corre del lado de Inngest, no en tu app.) Hace dos trabajos: elegir el nombre de tu evento, y aplanar el payload a los pocos campos que realmente usas. El código de tu función se queda libre de JSON específico del proveedor.

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

La respuesta: una vez. Una función solo dispara con el nombre de evento al que está registrada. stripe/charge.refund.failed y customer/refund.investigation_needed son nombres distintos, así que despiertan funciones distintas (o ninguna), sin importar que aterrizaran en el mismo instante. El nombre del evento es la clave de enrutamiento.

Por eso también nombrar no es cosmético. Un solo error de tipeo, customer/email_received donde la función escucha por customer/email.received, y la función calladamente nunca corre. Nada da error; el trabajo simplemente no ocurre. El panel es tu red de seguridad: los eventos que no coinciden con ninguna función aparecen en un stream sin coincidencias aparte que puedes vigilar.

Localmente, no hay URL que pegar. Todo lo de arriba es el camino de producción. En tu laptop no tienes URL pública, y Stripe no puede alcanzar localhost. Así que mientras construyes, haces tú mismo el papel del webhook: send_event (o el botón "Send to Dev Server" del panel dev) inyecta el evento exacto que un webhook real habría producido. Por eso el ejercicio práctico de abajo prueba con send_event y nunca toca Stripe.

Vale la pena retener la división:

Cómo entra el evento
ProducciónStripe hace POST a tu URL de webhook en vivo; se vuelve un evento en tu stream
Dev local (tú)inyectas el evento ya formado con send_event

El código de tu función es idéntico de cualquier modo; solo reacciona al nombre del evento y nunca sabe si el evento vino de un webhook real o de tu send_event.

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, cuando el mismo evento llega dos veces

El mismo evento a veces te llegará dos veces. Un cliente hace clic en "Emitir reembolso", la página está lenta, y el clic dispara dos veces; o la solicitud pasa pero el acuse de vuelta a quien llamó se pierde, así que quien llamó reintenta. De cualquier modo tu worker ahora ve dos eventos customer/refund.requested para un solo reembolso real. Si lo emite en cada uno, al cliente se le reembolsa dos veces.

Este es el bug más común en los sistemas de eventos, no un caso raro de borde. Los emisores siguen reintentando hasta recibir un acuse (las redes pierden paquetes, los servidores se reinician, los endpoints agotan el tiempo de espera), así que lo que se te promete es entrega al menos una vez, nunca exactamente una vez. La cura es hacer inofensiva la segunda copia: actúa sobre la primera, nota el duplicado, sáltatelo. Esa propiedad tiene un nombre. Algo es idempotente cuando ejecutarlo dos veces deja el mismo resultado que ejecutarlo una vez.

Inngest incorpora dos capas de esto.

Capa 1: el ID del evento siembra en el origen. Cuando envías un evento tú mismo (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}", # idempotency key: identical on every retry
),
])

Si un segundo evento con el mismo id se envía dentro de la ventana de deduplicación (24 horas por defecto), Inngest descarta el duplicado. Mismo evento lógico, mismo id, solo una ejecución de función. La clave debe ser idéntica en cada duplicado, ese es todo el punto. Constrúyela a partir de algo estable sobre la solicitud (aquí, el id del pedido), nunca a partir de una marca de tiempo o un valor aleatorio, que cambia en cada envío y derrota la deduplicación en silencio.

Así también domas el webhook reintentado del inicio de esta sección. No pones el id directamente en un evento de webhook, pero quien convierte el POST en evento (el transform alojado, o tu propia ruta receptora) lo pone a partir del id de evento propio del proveedor. Stripe estampa un id único en cada evento y lo reenvía sin cambios en un reintento, así que el webhook reentregado lleva el mismo id y deduplica exactamente como un evento que enviaste tú mismo.

Capa 2: idempotencia a nivel de paso. Dentro de una función, cada step.run se identifica por su nombre. Si una función se cae entre el paso 3 y el paso 4, el reintento vuelve a correr el código de la función desde el principio, 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 normal 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 en el reintento.

@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",
lambda: 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",
lambda: write_audit_refund_issued(order_id=order["id"], refund=refund),
)

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

Si esta función se cae durante el paso 3, el reintento vuelve a entrar al paso 1 (obtiene los datos del pedido cacheados, sin llamada a la BD), vuelve a entrar al paso 2 (obtiene los datos del reembolso cacheados, sin llamada a Stripe), corre el paso 3 de verdad, retorna. La tarjeta del cliente se cobra una vez, aunque la función haya corrido tres veces. Esta es la propiedad que más importa. Es lo que hace a Inngest cualitativamente distinto de una cola con un bucle de reintentos.

(El paso 1 pasa su único argumento de forma posicional. Los pasos 2 y 3 envuelven su llamada en un lambda en cambio, porque step.run solo reenvía argumentos posicionales, así que un lambda es cómo le entregas a un paso una llamada que usa argumentos con nombre. Cualquiera de las dos formas funciona, y el lambda también hace del cuerpo del paso una unidad autónoma que Inngest puede memoizar.)

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

La memoización da completitud de paso exactamente-una-vez desde el punto de vista de la función: una vez que un paso se registra como exitoso, nunca vuelve a correr. Pero hay una ventana estrecha. Si un paso llama a Stripe y el proceso muere después de que Stripe cobra pero antes de que Inngest registre el resultado, el reintento vuelve a llamar a Stripe, porque para Inngest el paso nunca terminó. El arreglo es emparejar la memoización de pasos con la propia clave de idempotencia del proveedor (el encabezado Idempotency-Key de Stripe, o el id de deduplicación que expongan tus otros proveedores). Las dos son complementarias, no sustitutas: step.run mantiene la lógica interna de tu función exactamente-una-vez; la clave del proveedor mantiene el efecto secundario externo exactamente-una-vez.

Comprobación rápida. Verdadero o falso. (a) step.run hace el paso idempotente solo si la función dentro también es idempotente. (b) Un evento con un ID duplicado fuera de la ventana de deduplicación será tratado como un evento nuevo. (c) Si step.run falla a mitad de 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 correr los pasos anteriores.

Respuestas: (a) Falso: step.run da al paso a-lo-sumo-una-vez-al-tener-éxito por sí mismo; no necesita que el código dentro sea idempotente. Una vez que un paso se registra como exitoso, su cuerpo nunca vuelve a correr en el reintento. La única excepción es la ventana de la nota de arriba: si el proceso muere después de que Stripe cobró pero antes de que Inngest registrara el paso, el reintento vuelve a llamar a Stripe, que es exactamente por qué una clave de idempotencia del proveedor lo respalda. La lógica interna de tu función, en cambio, nunca tienes que hacerla idempotente a mano. (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 está memoizado; Inngest sabe que el paso 3 falló en el intento 1 y reintenta solo el paso 3 en el intento 2. Los pasos exitosos anteriores 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 a 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 finanzas, publicar en Slack. Cinco reacciones, todas independientes, todas a partir de un evento.

El patrón de Inngest: suscribe muchas funciones al mismo evento. Sin código de fan-out; solo múltiples 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 disparan, 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 normal. 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 manager (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 del cliente por cada cliente Pro, que podrían ser 500 o 5.000 según la semana. La función padre envía N eventos:

async def fan_out_per_customer_events(
ctx: inngest.Context,
customers: list[str],
run_day: str, # pinned by the caller (the cron's scheduled date), never date.today()
) -> int:
events = [
inngest.Event(
name="customer/health_check.requested",
data={"customer_id": cid},
id=f"daily-health-{cid}-{run_day}", # stable id: identical on every retry
)
for cid in customers
]
# ctx.step.send_event memoizes the send, so a retry of this function
# does not re-fire the fan-out (and even if it did, the stable ids dedup).
await ctx.step.send_event("fan-out", events)
return len(events)

Esos 5.000 eventos salen en un solo paso send_event (una lista grande se trocea en unas pocas llamadas por lotes por debajo, no literalmente una sola solicitud HTTP). 5.000 ejecuciones de función disparan, 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 aguas abajo. La función de cron retorna en segundos; el fan-out corre al ritmo que permitan las políticas de control de flujo de Inngest.

La delegación a subagentes es un caso especial de fan-out. Dentro de la ejecución de un worker, delegas subtareas a otros tipos de worker enviando más eventos (await ctx.step.send_event(...), así la delegación queda memoizada como cualquier otro paso). El padre no espera a los hijos a menos que use explícitamente step.invoke (que corre una función hija y espera su resultado) para recolectar sus resultados.

Predice. Tienes tres funciones, todas disparadas por customer/email.received: el agente de soporte al cliente que redacta una respuesta (15 segundos), un contador de analítica (50 ms), y un "detector de VIP" que comprueba si el cliente es de alto valor (200 ms). Cuando llega un correo, ¿cómo se ve la latencia visible para el usuario en cada uno? Tres opciones: (a) las tres se suman a ~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 latencia compartida en absoluto. Confianza 1-5.

La respuesta: (c). Cada función es su propia ejecución, en su propia ranura de proceso. El agente de soporte no bloquea al contador de analítica; el detector de VIP no bloquea al agente. Desde fuera, la latencia de cualquier función en particular es solo el tiempo propio de esa función. Por eso el fan-out escala: los consumidores están aislados, y si el agente se cae, el contador de analítica no se ve afectado. La única salvedad, que el Concepto 11 desarrolla: este aislamiento es entre funciones distintas. Cuando una sola función hace fan-out hacia miles de ejecuciones de sí misma, un límite de concurrencia hace deliberadamente que las ejecuciones posteriores hagan cola, así que esos hermanos de la misma función sí esperan su turno. Funciones distintas nunca se bloquean entre sí; muchas ejecuciones de la misma función pueden hacerlo.

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

La Parte 1 trató sobre cómo el trabajo llega al worker. La Parte 2 trata sobre qué pasa cuando ese trabajo se rompe a mitad de camino.

Imagina un turno de un worker real. Llama a un agente, el agente llama a unas pocas herramientas, y esas herramientas tocan una base de datos, una API de pagos y un modelo. Eso son varias llamadas de red seguidas, y cualquiera de ellas puede fallar: un timeout, una conexión caída, un servicio que está caído unos segundos. Sin protección, un solo fallo pequeño tira todo lo que el worker acaba de hacer y empieza el turno entero de nuevo desde el principio.

La durabilidad es el arreglo, y es simple de decir sin rodeos: cuando algo falla a mitad de camino, los pasos que ya terminaron quedan terminados, y el worker retoma desde el punto que se rompió en lugar de empezar de nuevo. En la imagen del sistema nervioso, esto es el reflejo: simplemente ocurre, rápido, sin que el agente tenga que pensarlo.

Inngest te da esto con una herramienta, step.run, y un mecanismo llamado memoización trabajando debajo. La Parte 2 cubre ambos, luego las versiones basadas en tiempo (step.sleep y step.wait_for_event), cómo se comportan los reintentos, y los ayudantes step.ai.

Si estás hojeando: los dos que más importan son el Concepto 6 (step.run) y el Concepto 7 (memoización). Todo lo demás de la Parte 2 se construye sobre ellos, así que lee esos dos despacio. Una vez que hacen clic, los Conceptos 8 a 10 van rápido.

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

Una función normal de Python corre una vez, de arriba abajo. Si se cae a mitad, empiezas de nuevo desde el principio. Si hace tres llamadas a API antes de caerse, el siguiente intento vuelve a hacer esas tres llamadas, y paga por ellas, y posiblemente vuelve a cobrarle a alguien, otra vez.

Una función de Inngest es duradera. Cada operación que quieras que quede en un punto de control se envuelve en step.run(name, fn, ...). Inngest entonces conduce la función un paso a la vez. Corre tu manejador desde el principio, y cuando llega a un paso que aún no ha hecho, corre ese paso, guarda el resultado, y vuelve a entrar al manejador desde el principio otra vez, esta vez devolviendo la salida guardada de cada paso completado en lugar de volver a ejecutarlo. La función "se pone al día" hasta donde quedó, toma el siguiente paso y repite. (Así que el cuerpo del manejador corre muchas veces por una función, una vez por paso, no solo cuando algo falla.)

¿Por qué volver a entrar al manejador siquiera, en vez de solo continuar donde quedó? Por los dos programas de la apertura. El motor y tu función son dos programas separados. Un programa no puede pausarse a mitad del código de otro y mantener su lugar. Así que el motor conduce tu función de la única forma que puede: llama a tu función por la web, la corre hasta el siguiente paso sin terminar, deja que ese paso corra, y recibe el resultado de vuelta. Luego almacena ese resultado de su lado y llama a tu función otra vez para el siguiente paso, devolviendo todo lo que ya ha almacenado.

  ENGINE                                   YOUR FUNCTION (host)
| call: run from the top -----------> runs to step 1, does it
| <---------------------------------- returns step 1's result
stores result 1
| call again -----------> step 1 from memo, runs step 2
| <---------------------------------- returns step 2's result
stores result 2
| call again -----------> steps 1-2 from memo, runs step 3
| ...and so on, one call per step

Ese es todo el mecanismo. "Vuelve a correr desde el principio, los pasos completados desde la memoización" es solo el motor llamando a tu función una vez por paso y guardando los resultados de su lado. Y como los resultados viven del lado del motor, un paso terminado sobrevive aunque tu host se caiga y se reinicie a mitad de ejecución.

@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).
# step.run forwards only positional args, so a call that needs keyword
# args is wrapped in a lambda (the step body becomes a no-arg callable).
response = await ctx.step.run(
"run-agent",
lambda: 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",
lambda: 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",
lambda: post_slack_for_review(response=response),
)

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

Cinco pasos. Cada uno queda en un punto de control de forma independiente.

Lo que te compra la durabilidad, en tres fallos que pueden golpear esta función exacta:

Si esto fallaSin step.runCon step.run
El agente agota el tiempo de espera (paso 3)el reintento vuelve a cargar el cliente y el hilo y vuelve a correr el agente desde cero, pagando los tokens de OpenAI dos veceslos pasos 1-2 vuelven desde la memoización; solo el paso 3 reintenta, e Inngest maneja el error transitorio por ti
El proceso es matado entre los pasos 3 y 4 (despliegue, reinicio, OOM)la respuesta del agente se pierde; el correo queda sin responder hasta que alguien lo notala función se reanuda tras el reinicio: los pasos 1-3 vuelven desde la memoización en milisegundos, los pasos 4-5 corren, el cliente recibe la respuesta
Slack devuelve un 503 (paso 5)pierdes el trabajo, o escribes a mano reintento-y-backoff solo para SlackInngest reintenta el paso 5 con backoff hasta que Slack se recupera o se agota el presupuesto de reintentos; los pasos 1-4 quedan hechos, el borrador ya está guardado

No escribes ningún bucle de reintentos, ninguna comprobación de "¿ya hice esto?", ni ninguna máquina de estados propia. La máquina de estados es la secuencia de llamadas step.run.

La única regla de step.run. Un paso debe ser seguro de volver a correr: si falla e Inngest lo corre de nuevo, la segunda corrida no debe corromper nada.

  • Las funciones puras son automáticamente seguras.
  • Las llamadas a API idempotentes son seguras (el idempotency_key de Stripe, las herramientas de tu propio servidor MCP): una repetición no tiene efecto.
  • El trabajo no determinista sigue siendo seguro de volver a correr; solo puede que obtengas un resultado distinto en el reintento. Un ID aleatorio nuevo, o una llamada a un LLM a temperatura por defecto, diferirá en un segundo intento. Eso está bien para la respuesta de un agente (cualquier borrador válido sirve). Cuando el valor exacto debe ser estable a través de los reintentos, fíjalo: pasa una semilla, o genéralo una vez en su propio paso anterior y léelo de vuelta.

Comprobación rápida. Verdadero o falso. (a) El cuerpo de la función se vuelve a ejecutar desde el principio cada vez que Inngest avanza al siguiente paso, no solo en los reintentos, volviendo a correr el código simple (asignaciones de variables, ramificación) entre tus llamadas step.run. (b) Si un paso tarda 30 segundos en completarse, y la función se cae 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 sorprende a la gente: Inngest vuelve a entrar a tu manejador desde el principio en cada paso, saltándose los pasos completados desde la memoización. Así que el código fuera de step.run corre muchas veces en una ejecución limpia, no solo en los reintentos. El código dentro de un paso corre una vez, luego vuelve desde la memoización. (Los import a nivel de módulo se cargan una vez sin importar nada; solo el cuerpo del manejador se vuelve a correr.) Esta es la razón real para mantener el trabajo dentro de step.run. (b) Falso: step.run es la unidad atómica; si un paso se interrumpe, el reintento vuelve a correr el paso entero. Si tu paso es tan largo que no se le puede permitir reiniciar, lo divides en pasos más pequeños. (c) Verdadero: el almacén de salidas de pasos es parte de Inngest, no de tu BD. Por eso puedes reproducir 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, el mecanismo 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 mirarlo de cerca porque cada otra primitiva de Inngest se construye sobre él.

Cuando llamas a await ctx.step.run("load-customer", load_customer_by_id, "c-4429"), Inngest mantiene un almacén de memoización indexado por (run_id, step_name). La misma línea se comporta distinto según si esa clave ya está llena:

  • Primer intento: la memoización está vacía, así que load_customer_by_id corre de verdad, e Inngest guarda lo que devuelve antes de entregarte el resultado.
  • Cada repetición posterior (Inngest vuelve a entrar al manejador cuando pasa al siguiente paso, y de nuevo en cualquier reintento): la memoización ya contiene load-customer, así que load_customer_by_id no corre, la llamada a la BD nunca ocurre, y el valor guardado vuelve en milisegundos.

Por esto los reintentos son baratos (el trabajo caro ya está cacheado), por esto la durabilidad es correcta (el trabajo caro nunca ocurre dos veces), y por esto "el cuerpo se vuelve a correr de arriba abajo" está bien a pesar de sonar derrochador: el trabajo dentro de los pasos no se vuelve a correr en realidad; solo el código de orquestación entre pasos.

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 guardan su salida, luego save-draft se cae y notify nunca se alcanza. El Intento 2, el reintento, vuelve a correr la función desde el principio, pero load-customer, load-thread y run-agent vuelven desde el almacén de memoización a costo cero (el paso caro 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) contiene las salidas guardadas. Tres propiedades: los reintentos son baratos, los efectos secundarios ocurren una vez, y los nombres de paso son las claves de la memoización.

El paso completado se paga una vez, no una vez por reintento.

La implicación que sorprende a los usuarios nuevos. El código fuera de step.run corre cada vez que Inngest vuelve a entrar al manejador, que es una vez por paso, no solo en los reintentos. Si haces esto:

async def handle_email(ctx: inngest.Context) -> dict[str, str]:
# ANTI-PATTERN: this re-runs every time Inngest advances a step. 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 de nuevo en cada paso que toma la función, incluso sin fallos. Este ejemplo de un solo paso ya la llama dos veces en una ejecución limpia (una vez por cada reentrada al manejador), y cada paso que agregas es una llamada más. Así que a $0,10 la llamada ya está desperdiciando dinero antes de que algo se rompa, y un reintento lo paga todo de nuevo. El arreglo es envolver lo caro 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á memoizado; los reintentos no vuelven a pagar por él.

El nombre del paso es la clave de la memoización. El SDK de Python no colisiona ante un nombre repetido; los autonumera por orden de llamada (load-customer, luego load-customer:1, luego load-customer:2), así que cada uno obtiene su propia ranura de memoización. Pero no te apoyes en eso: los autonúmeros no llevan significado, así que una traza del panel que muestre load-customer:7 no te dice nada sobre cuál cliente, e insertar o quitar un paso desplaza cada número posterior. Dale a cada llamada un nombre estable, derivado de los datos, en cambio, step.run(f"load-customer-{customer_id}", ...) en un bucle, para que la clave de memoización quede atada a los datos, no al orden de llamada.

Predice. Tu función tiene tres pasos. El paso 1 (load-customer) cuesta $0,01 en llamadas a la BD 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 BD y tarda 50 ms. El paso 2 falla el 30% de las veces por los límites de tasa 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 del paso 2 cuesta el costo del paso 2 solamente ($0,20); el paso 1 está memoizado y se salta, y el paso 3 aún no ha corrido. Con (b), el paso 1 está fuera de step.run, así que se vuelve a ejecutar en cada reintento del paso 2: cerca de $0,21 por reintento ($0,01 por el paso 1 más $0,20 por el paso 2). El paso 3 no es el costo aquí, corre una vez, después de que el paso 2 finalmente tiene éxito; el punto es que cualquier trabajo antes de un paso que falla se vuelve a correr a menos que lo envuelvas. A lo largo de mil correos con una tasa de reintentos del 30%, eso son cerca de $3 de llamadas a la BD del paso 1 desperdiciadas, y el peligro real es más grande que el dinero: si el paso 1 tuviera un efecto secundario (una escritura, un cobro), dejarlo fuera de step.run hace que ese efecto secundario ocurra de nuevo en cada reintento. Envuelve en step.run todo lo que no quieras que se vuelva a ejecutar. No es opcional una vez que entiendes el mecanismo.

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 por "el usuario pasó a pago" dentro de 7 días y envía un correo distinto según lo que vea.

En una función normal de Python, "espera tres días" significa mantener un proceso abierto tres días. Eso es insostenible: tu proceso se reinicia, tu hosting te cobra 72 horas de cómputo inactivo, tu temporizador se pierde. En Inngest, "espera 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 en el momento correcto, con todas las salidas de los 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 por tiempo, espera por otro evento. La función se suspende hasta que llega un evento que coincide, o hasta que expira un timeout que fijas. Esto es lo que hace a 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 ocurre, de arriba abajo:

  the function reaches wait_for_event   ->  it SUSPENDS  (zero compute)
|
| a human sees the Slack note, clicks Approve in your admin UI
| the UI sends a refund/approval.decided event
v
Inngest matches that event to THIS waiting run (if_exp picks the right one)
|
v
the function RESUMES, with the event as the `approval` value
|
v
the refund step runs -> Stripe refund happens, after the human approved

La única parte sutil es la coincidencia en el medio: if_exp es lo que hace que el evento de aprobación despierte esta ejecución de la solicitud y no la de otra persona.

step.sleep y step.wait_for_event son timeouts que no pagas. La función luce síncrona en tu código ("espera tres días, luego envía el correo"), pero la semántica del runtime 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 poller, y escribirías mil líneas en lugar de tres.

Comprobación rápida. Tres afirmaciones. Marca cada una como Verdadera o Falsa. (a) Si step.sleep se fija para 30 días y tu servicio se redespliega cinco veces en esos 30 días, el sleep continúa ininterrumpido 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 sleep de siete días del plan gratuito Hobby. (b) Falso: al agotar el tiempo, wait_for_event devuelve None. Tu código lo comprueba y decide qué hacer (rechazo, escalamiento, aprobación por defecto, lo que sea la política). (c) Falso en código secuencial normal: una función llega a un wait_for_event, se suspende, y alcanza el siguiente solo después de que el primero se reanuda, así que las dos esperas corren en secuencia, y un solo evento que coincide reanuda cualquiera que esté suspendida en ese momento. Se solaparían solo si las lanzaras como pasos paralelos, un patrón más allá de este curso. La regla del día a día: un evento reanuda un punto de espera.

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 pocos segundos a unos pocos minutos entre intentos. Después de que el reintento final falla, la ejecución entra en un estado failed y se queda ahí para inspección y (opcionalmente) replay. Puedes ajustarlo por función: retries=10, o retries=0 para no reintentar nunca. Para saltarte los reintentos ante un fallo específico (una tarjeta declinada, un 401), lanza inngest.NonRetriableError desde dentro del paso, como hace el ejemplo de abajo.

@inngest_client.create_function(
fn_id="charge-customer",
trigger=inngest.TriggerEvent(event="order/checkout.completed"),
retries=2, # transient Stripe errors (503, timeout) retry twice
)
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 inngest.NonRetriableError as e:
# call_stripe_charge raises NonRetriableError on a declined card, which
# tells Inngest NOT to retry the step (a decline will not become an
# approval on attempt 2). So we land here on the FIRST failure, with no
# wasted retries, mark the order, and kick off the dunning flow.
await ctx.step.run(
"mark-failed",
lambda: 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"}

Importan tres patrones.

Patrón 1: fallos transitorios vs. permanentes. Inngest reintenta todo por defecto, pero algunos errores no son transitorios. Un error de tarjeta declinada de Stripe volverá a declinarse en el reintento. Un 401-no-autorizado de tu API aguas abajo no se volverá un 200 solo porque esperes. Tu función debe atraparlos específicamente y manejarlos: escribir en tu BD, emitir un evento aguas abajo, retornar limpio, para que no desperdicien presupuesto de reintentos en intentos sin esperanza. El NonRetriableError de Inngest le dice explícitamente a Inngest que se salte 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 una excepción se reintenta. Después de que los reintentos a nivel de paso se agotan, 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 igual obtiene 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. Una función completamente fallida no desaparece; aterriza en la vista de "ejecuciones fallidas" del panel con su traza completa, las salidas de pasos y la excepción, junto a un botón Replay. Arregla el bug, despliégalo, reproduce, sin ningún manejador de dead-letter que escribir. (Replay es una ejecución nueva desde el principio, no una reanudación que preserva la memoización, así que mantén idempotentes los pasos con efectos secundarios; el Concepto 14 lo cubre por completo.)

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 (~1s, ~2s, ~5s, ~12s); en el 4.º reintento, Stripe volvió, el cobro tiene éxito. Ahora corre el paso 4, y el servicio de datos está caído con un 500. ¿Inngest reintenta la función entera, 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 servicio de datos vuelve, el paso 4 se completa, y la función tiene éxito. El cobro de Stripe del paso 2 no se vuelve a emitir, porque la salida del paso 2 quedó memoizada tras su reintento exitoso. Al cliente se le cobra exactamente una vez aunque la función haya pasado 20 segundos en 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 BD, llamadas a API, escrituras de archivos, invocaciones de agentes, y eso incluye tus llamadas a LLM. Así que aquí está el titular para las llamadas de IA en Python, de entrada: sigues usando ctx.step.run. Inngest sí trae primitivas step.ai específicas de IA, pero en Python o no están disponibles o son de nicho, y recurrir a ellas es el giro equivocado común que este concepto existe para evitar.

Nota importante de Python-vs-TypeScript de entrada. El módulo step.ai de Inngest tiene dos métodos, y tienen distinto soporte de lenguaje. 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 prompt/respuesta del LLM que agrega el step.ai.wrap de TypeScript. (Verificado contra la documentación de Inferencia de IA a mayo de 2026.)

step.run envuelve la ejecución del agente, no una llamada directa al modelo. En este curso tu worker es un agente del OpenAI Agents SDK, así que el agente hace las llamadas al LLM y a las herramientas, no tú. Envuelves toda la ejecución del agente en ctx.step.run(...). A Inngest no le importa qué hay dentro del paso; tu agente es solo la función que le entregas. Registra la entrada del paso y el resultado del agente, reintenta el paso ante un fallo transitorio, y lo memoiza al tener éxito para que los pasos posteriores nunca vuelvan a pagar el costo del agente.

@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 = await ctx.step.run(
"load-thread", load_thread, ctx.event.data["thread_id"],
)

# The agent makes the model and tool calls internally. You wrap the whole
# AGENT RUN in step.run, so Inngest sees it as one step: it records the
# input and the agent's result, retries on a transient failure, and
# memoizes on success so later steps do not re-pay the agent's cost.
result = await ctx.step.run(
"run-agent",
lambda: run_support_agent(thread=thread),
)

return {"summary": result.summary}

El panel muestra esta ejecución como load-thread y luego run-agent, cada uno con su entrada y salida. Lo único que no obtienes, frente al step.ai.wrap de TypeScript, es telemetría específica del LLM (conteos de tokens, nombre del modelo) desglosada en la vista de IA del panel; el propio trazado del Agents SDK cubre eso.

La ejecución del agente es un paso. Como envolviste todo el agente, las llamadas al modelo y a las herramientas dentro de él no son pasos de Inngest separados. Si la ejecución del agente falla a mitad e Inngest reintenta run-agent, el agente entero se vuelve a correr desde el inicio, volviendo a pagar los tokens que ya gastó en ese intento. Eso suele estar bien: un borrador de agente es barato de rehacer, y cualquier borrador válido sirve. Cuando una ejecución de agente es lo bastante costosa como para que no quieras rehacerla por entero, divide el trabajo en piezas más pequeñas, cada una su propio step.run (carga y recuperación en sus propios pasos, luego una llamada de agente más corta), para que un reintento rehaga solo la pieza que falló.

Trazas de pasos y 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 queda almacenado y 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 sujetos a HIPAA, GDPR, PCI), no pases el contenido en bruto al cuerpo del paso. Redacta, hashea, 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 para configurar. La misma disciplina aplica al propio trazado del OpenAI Agents SDK si lo habilitas. Trata las trazas de pasos como tratarías cualquier registro de producción: útiles por defecto, regulados por política.

step.ai.infer (soportado en Python, pero de nicho). Rara vez recurrirás a él; step.run es el valor por defecto para cada llamada de IA en este curso. Su único propósito: en lugar de llamar a OpenAI desde tu proceso, le pides a la infraestructura de Inngest que haga la llamada para que tu proceso pueda liberarse mientras la solicitud está en vuelo. En plataformas sin servidor que cobran por el tiempo en vuelo, y para inferencias largas (Deep Research, lotes grandes de embeddings), eso ahorra dinero real; para llamadas de menos de un segundo en un servidor siempre activo solo agrega latencia. Si lo usas, saca la firma exacta de la documentación de Inferencia de IA para tu versión; vive en el espacio de nombres 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, envolver la ejecución de tu agente en ctx.step.run("run-agent", run_support_agent, ...) la hace duradera, reintentada ante fallos transitorios, y memoizada al tener éxito. (b) step.ai.infer es un requisito obligatorio 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 más barata la ejecución de la función.

Respuestas: (a) Verdadero: este es el patrón recomendado de Python. La ejecución del agente 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 sin servidor, 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 sin servidor que cobra por el tiempo en vuelo Y (ii) la llamada es lo bastante larga como para que el ahorro de descargar la solicitud domine sobre la sobrecarga de orquestación agregada. Para llamadas de menos de un segundo en servidores siempre activos, step.run simple gana.

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: The normal pattern. Wrap the Runner.run call (the whole
agent run) in step.run: durable, retried on transient failures,
memoized, with the standard step trace.

Version B: The niche exception, for comparison. step.ai.infer can
only offload ONE model call, not a whole agent, so write a SEPARATE
small function that makes a single direct OpenAI completion via
step.ai.infer (the Python-supported primitive that hands that one
call to Inngest's infrastructure to save serverless compute cost).
This is the one place you call the model directly instead of letting
the agent do it.

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

Las Partes 1 y 2 pusieron a tu worker a correr y a sobrevivir caídas. La Parte 3 trata sobre correrlo a escala real: evitar que un worker ocupado abrume todo a su alrededor, y recuperarse rápido cuando algo sale mal en masa. Los cinco conceptos, en términos llanos:

  • Concurrencia y throttling (Concepto 11): limita cuántas ejecuciones ocurren a la vez, y qué tan rápido arrancan las nuevas, para que una avalancha de eventos no abra mil conexiones de base de datos ni rebase tu límite de tasa de OpenAI en un solo segundo.
  • Prioridad y equidad (Concepto 12): asegúrate de que un cliente que envía 500 correos no empuje a todos los demás al fondo de la fila.
  • Batching (Concepto 13): maneja 10.000 eventos como unas 100 ejecuciones agrupadas en lugar de 10.000 separadas.
  • Replay y cancelación (Concepto 14): tras un mal despliegue, vuelve a correr las ejecuciones que fallaron, sobre el código arreglado; o cancela trabajo que ya no quieres que ocurra.
  • Gates de aprobación humana (Concepto 15): pausa al agente y espera a una persona antes de una acción de alto riesgo, como un reembolso grande.

Juntos convierten un worker que corre en uno que puedes poner con seguridad frente a clientes que pagan.

Concepto 11: concurrencia y throttling

Tu prototipo maneja unos pocos correos por minuto y está bien. Luego una mañana ocupada envía 1.000 de golpe, tu worker intenta correr los 1.000 al mismo tiempo, y abre 1.000 llamadas a OpenAI y 1.000 conexiones de base de datos en el mismo instante, agotando ambas. Esta es la brecha más común entre un prototipo y producción, y el arreglo son dos límites pequeños, una línea cada uno:

  • Concurrencia es cuántas ejecuciones pueden correr al mismo tiempo.
  • Throttling es qué tan rápido se permite que arranquen las ejecuciones nuevas.
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 mucho 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 mucho 100 ejecuciones nuevas arrancan por minuto. El 101.º evento espera aunque haya holgura de concurrencia.

Por qué normalmente quieres ambos. La concurrencia protege tus sistemas aguas abajo de demasiadas llamadas a la vez (el problema de las 1.000 conexiones de arriba). El throttle los protege de una ráfaga: si 500 correos aterrizan a las 9:00 en punto, no quieres que 500 ejecuciones arranquen en el mismo segundo, aunque tengas holgura de concurrencia; el throttle reparte los arranques.

La parte sutil, y la razón por la que un límite de concurrencia solo no siempre basta: la concurrencia limita cuántas ejecuciones están en vuelo, no qué tan rápido arrancan las nuevas. Si tus ejecuciones son rápidas, una ranura liberada se llena en el instante en que una termina. Así que concurrency=10 igual puede lanzar cientos de arranques por segundo, más que suficiente para rebasar un límite de "30 solicitudes por minuto" aunque solo corran 10 a la vez. Así que empareja la perilla al límite que proteges: un límite de conteo (un pool de 20 conexiones de base de datos) quiere concurrencia; un límite de tasa (los 30 por minuto de OpenAI) quiere throttle. Cuando las ejecuciones son lentas, la concurrencia acota la tasa también como efecto secundario y puede que no necesites throttle; cuando son rápidas, solo el throttle sostiene la tasa.

Concurrencia por clave. Un solo límite de concurrency aplica a la función globalmente. Un patrón más interesante es la concurrencia por clave: limita por alguna propiedad del evento. Pasas una lista de límites en lugar de uno:

concurrency=[
inngest.Concurrency(limit=10), # global cap
inngest.Concurrency(limit=2, key="event.data.customer_id"), # per-customer cap
],

Esto dice: como mucho 10 funciones corriendo globalmente, Y como mucho 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 hacen cola detrás. Mientras tanto, los correos de otros clientes fluyen normal; 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.

Imagina toda la política bajo una ráfaga de las 9am: el throttle frena qué tan rápido arrancan las ejecuciones, el límite de concurrencia sostiene cuántas corren a la vez, y la clave por cliente evita que una avalancha tome todas las ranuras, mientras todo lo demás espera en una cola duradera.

Control de flujo bajo una ráfaga de carga, de izquierda a derecha. Etapa 1, llega una ráfaga de 500 correos de cinco clientes con un cliente (rosa) inundando. Etapa 2, un throttle puesto en 100 por minuto limita qué tan rápido arrancan las ejecuciones, suavizando el pico. Etapa 3, un límite de concurrencia de 10 acota lo que corre a la vez, y un límite por cliente de 2 significa que el cliente que inunda sostiene como mucho dos de las diez ranuras mientras los demás siguen fluyendo. Etapa 4, el resto espera en una cola duradera y se drena a medida que se liberan ranuras, así que nada se descarta.

Nada se descarta; el trabajo hace cola. Tres perillas deciden qué corre, qué tan rápido arranca y quién espera.

Comprobación rápida. Tres afirmaciones, Verdadero o Falso. (a) Si pones concurrency=10 y 1.000 eventos llegan de golpe, 990 de ellos se descartan. (b) El throttling y los límites de concurrencia reducen ambos 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; hacen cola. 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 arranque; la concurrencia limita las ejecuciones en vuelo. Ninguno descarta trabajo; ambos modelan 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 en un pico se modela: 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 alcance 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, escalamiento multiinquilino

Los límites de concurrencia funcionan. La concurrencia por clave agrega equidad básica. Los sistemas multiinquilino de grado 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 cuota justa (ningún inquilino solo puede monopolizar el sistema ni siquiera dentro de su límite de concurrencia).

Prioridad. Inngest evalúa una expresión de prioridad en cada evento; las ejecuciones con mayor prioridad saltan la cola por delante de las de menor prioridad. Es un argumento más en el mismo create_function del Concepto 11:

priority=inngest.Priority(
# Higher number wins (range -600..600). The producer puts the tier's
# priority on the event directly: Enterprise = 100, Pro = 0, Free = -100.
run="event.data.tier_priority",
),

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 obtiene la siguiente.

Programación de cuota justa. Cuando tienes cientos de inquilinos compitiendo por el mismo pool global de concurrencia, FIFO más prioridad no basta. Un solo inquilino que envía una ráfaga igual puede ocupar la mayoría de las ranuras por minutos. La programación de cuota justa, implementada vía el parámetro key en la concurrencia con un dimensionamiento 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 20 inquilinos están activos, eso es como mucho 60 ranuras solicitadas pero solo 50 disponibles. La cuota justa los rota, cada inquilino obtiene algo de porción, nadie queda excluido.

Predice. Tienes una función de soporte al cliente con concurrency=10 y concurrency=2 por cliente. También tienes prioridad configurada: Enterprise = alta, Free = baja. A las 9:00am, 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: se resuelve en tres pasadas, en este orden.

  1. per-customer cap (2 each)  ->  eligible pool = 2 from A, 2 from B, 2 from C   (6 runs)
2. priority sorts the pool -> B's 2 first (Enterprise), then A's 2 and C's 2 (Free, FIFO)
3. fill the 10 global slots -> all 6 fit, so 6 run now; the rest wait

A medida que cada ejecución termina, el siguiente evento en cola de ese cliente se vuelve elegible (pasada 1), y la siguiente ranura libre va al que espera con mayor prioridad (pasada 2). El límite por cliente es lo que evita que los diez eventos del Cliente C tomen toda la cola.

Lo que puedes verificar localmente, y lo que necesita Cloud

El control de flujo es el único lugar de este curso donde "córrelo y observa" no se sostiene del todo. De las cuatro perillas de los Conceptos 11 y 12, solo la concurrencia es observable en el dev server local: envía una ráfaga y verás correr solo N a la vez. Las otras tres las configuras y razonas localmente, luego confirmas el efecto en Inngest Cloud (o un branch deploy):

  • El throttle es un límite de tasa que el dev server no impone, así que localmente tus ejecuciones arrancan tan rápido como pueden, sin importar el límite. La configuración es correcta; la tasa solo muerde en Cloud.
  • La prioridad y la cuota justa solo aparecen bajo contención multiinquilino sostenida, una cola llena con muchos inquilinos compitiendo. Un puñado de eventos de prueba nunca crea eso, así que se quedan invisibles localmente aunque estén configuradas correctamente.

Así que para estas tres, "verificado" significa que la configuración se acepta y la función corre, y puedes razonar sobre el comportamiento. No concluyas "no se impone nada" a partir de un dev server callado; confirma el efecto real bajo carga en Cloud.

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: batching, procesamiento 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 una; las escribes en una inserción masiva. El trigger de batch 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 solo texto, lo cual es dramáticamente 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 batching es la herramienta correcta cuando el trabajo es naturalmente agrupable (embeddings, escrituras masivas a BD, correos masivos) y puedes tolerar hasta el equivalente de tu timeout en latencia antes de que el trabajo ocurra. Es la herramienta equivocada cuando cada evento requiere respuesta interactiva o cuando el orden importa entre eventos de formas impredecibles.

Comprobación rápida. Verdadero o falso. (a) Las funciones por lotes igual obtienen reintentos y memoización; el lote como un todo se memoiza de forma duradera. (b) Si el timeout 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 el lote entero con todos sus eventos aún en alcance. (b) Falso: ese es todo el punto del timeout. Tras 30 segundos la función corre con lo que sea que esté acumulado, aunque sea 1 evento. (c) Verdadero: este es el patrón de producción. Batch más concurrencia juntos limitan tu carga aguas abajo de forma agradable.

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. Desplegaste un bug; mil ejecuciones fallaron en las últimas seis horas. O tu API aguas abajo estuvo caída 30 minutos; todo lo que intentó llamarla durante esa ventana murió. O descubriste un error de lógica y quieres rehacer el trabajo de un día tras arreglarlo.

Primero, la distinción que hace tropezar a todos. Inngest te da dos formas en que un paso fallido puede volver a correr, y se comportan distinto:

  • Reintento automático (dentro de la misma ejecución). Cuando un paso lanza una excepción, Inngest reintenta la función con backoff, volviendo a entrar desde el principio. Los pasos completados vuelven 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 viste en la Quick Win, 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 el principio con tu código actualmente desplegado, cada paso volviéndose a ejecutar desde cero (un rerun obtiene un id de ejecución nuevo y vuelve a correr el primer paso, no una reanudación de la vieja). Así que en la práctica la memoización de la ejecución vieja no te salva aquí. Es para recuperación ante incidentes, no para saltarte trabajo completado.

Mantener estos claros es todo el concepto. La recompensa de la memoización vive en el reintento automático; Replay es un inicio fresco. Las dos filas de abajo son los mismos cinco pasos bajo cada camino:

Reintento frente a replay, la misma función de cinco pasos mostrada bajo cada uno. Fila superior, reintento automático dentro de la misma ejecución (run_id A1): los pasos 1 a 3 (load-customer, load-thread, run-agent) vuelven desde la memoización a costo cero, y solo el paso fallido save-draft se vuelve a correr, luego notify corre. Fila inferior, replay como una ejecución nueva (run_id A2): los cinco pasos se vuelven a ejecutar sobre el código actual y pagas la función entera de nuevo, así que una clave de idempotencia, no la memoización, es lo que evita un segundo reembolso real. La memoización protege dentro de una ejecución; la clave de idempotencia protege a través de los reruns.

La memoización te protege dentro de una ejecución; una clave de idempotencia, no la memoización, te protege a través de los reruns.

Dos primitivas de recuperación opuestas. Replay dice "este trabajo falló, quiero que corra de nuevo sobre el código arreglado". La cancelación masiva dice "este trabajo estaba en cola 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 corriendo tráfico real.

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 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 el principio sobre el código que sea que esté desplegado ahora.

Tres cosas que entender sobre el replay.

  • Replay usa tu código actualmente desplegado. Si desplegaste un arreglo entre cuando las ejecuciones fallaron y cuando las reproduces, las ejecuciones reproducidas usan el código nuevo. Este es todo el punto: toma una población de ejecuciones que murieron por un bug, despliega el arreglo, y vuelve a correrlas todas sin intervención.
  • Replay vuelve a ejecutar cada paso; no reutiliza la memoización de la ejecución vieja. Una ejecución reproducida es una ejecución nueva, así que cada paso corre de nuevo desde cero sobre el código arreglado. En cuanto a costo, planifica para el costo de la función entera 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 de la solicitud (para un reembolso, algo como (order_id, request_id)) y el proveedor trata una repetición como sin efecto. El worker mínimo de este curso omite esa clave por brevedad, su reembolso coincide por el cliente y escribe incondicionalmente, así que una versión de producción agregaría una antes de que se mueva dinero real.
  • El replay es de adhesión voluntaria. Las ejecuciones fallidas quedan en el panel hasta que actúas sobre ellas. No reintentan para siempre; no desaparecen. Esperan por ti.

La cancelación masiva es la inversa. A veces tienes miles de ejecuciones en cola o durmiendo que ya no quieres: una campaña se canceló, un cliente se dio de baja 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 limpio: sus llamadas step.sleep y step.wait_for_event no se reanudan, las ejecuciones en cola no arrancan, 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 cobros de Stripe a medias ni escrituras a BD desgarradas.

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

Tres patrones que esto habilita en la práctica:

  • La recuperación de "desplegamos un bug". Encuentra las ejecuciones fallidas en la ventana de tiempo del mal despliegue, arregla el bug, despliega el arreglo, reproduce los fallos. La experiencia del cliente: su correo no recibió respuesta por una hora pero eventualmente sí la recibió, sin que escribas ningún 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 da de baja el día 4. No quieres enviar los seguimientos del día 7 y del día 14. Cancela en masa las ejecuciones coincidentes de wait-for-event y sleep.
  • El replay de "migración de esquema". Cambiaste cómo el agente formatea los resúmenes; quieres los tickets de ayer re-resumidos con el formato nuevo. Encuentra esas ejecuciones (exitosas o no) y reprodúcelas; como un replay es una ejecución nueva desde el principio, 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 haga un doble cobro ni un doble envío.

El MCP del dev server hace accesible la recuperación sin salir de tu agente general. Durante el desarrollo puedes pedirle a la IA que use get_run_status para inspeccionar una ejecución fallida, luego recuperar el trabajo volviendo a disparar el evento sobre el código arreglado (dale un id de evento nuevo, ya que volver a disparar con el mismo id se deduplica a sin efecto por la semántica de idempotencia del Concepto 4). El botón Rerun del panel es el camino equivalente de un clic. De cualquier modo 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 ejecuta actualmente para terminar más rápido.

Respuestas: (a) Verdadero: un replay es una ejecución nueva desde el principio sobre lo que sea que esté desplegado ahora, que es por qué es la herramienta para la recuperación de arreglo de bug. (b) Falso: esta es la trampa. Un replay es una ejecución nueva que vuelve a ejecutar cada paso desde el principio, así que la memoización de la ejecución vieja no se traslada. Lo que evita que un efecto secundario reproducido dispare dos veces es la clave de idempotencia, no la memoización. (c) Verdadero: este es el camino que preserva la memoización, y es el que viste en la Quick Win. El paso completado se queda en un intento mientras el paso fallido 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 desgarradas.

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: gates HITL con step.wait_for_event, el Invariante 1 en el runtime

Algunas acciones son demasiado importantes para dejar que el agente las tome por su cuenta. Emitir un reembolso de $500, enviar un aviso legal, cerrar una cuenta: quieres que el agente investigue y proponga la acción, pero que una persona la apruebe antes de que ocurra de verdad. Esa pausa para un humano es un gate de aprobación, y es el único lugar en todo este sistema donde el worker se detiene y espera a alguien. (En los términos de Agent Factory esto es el Invariante 1, el humano es el principal: en una decisión de alto riesgo, la decisión de la persona es lo que corre, no la del agente.)

El step.wait_for_event de Inngest (Concepto 8) hace esto limpio. El agente corre hasta el punto de decisión, luego se suspende y espera un evento de aprobación. Un humano lo revisa (en Slack, una interfaz de admin, o correo) y hace clic en aprobar o rechazar; ese clic dispara el evento, la función despierta con el veredicto, y actúa. Tu código controla qué se le permite hacer al agente, no cómo razona.

@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).
# Keyword-arg calls are wrapped in a lambda; step.run forwards only positional args.
analysis = await ctx.step.run(
"agent-investigates",
lambda: 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",
lambda: 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",
lambda: escalate_to_senior_reviewer(request_id=request_id),
)
return {"status": "escalated_timeout"}

if not approval.data["approved"]:
await ctx.step.run(
"notify-rejected",
lambda: 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",
lambda: call_stripe_refund(request_id=request_id, amount_cents=amount_cents),
)

await ctx.step.run(
"audit-approved-refund",
lambda: 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 ocurre en runtime:

  • El agente corre (paso 1, de forma duradera).
  • La función decide si aplica el gate (lógica en código, libre de efectos secundarios).
  • Si tiene gate: una notificación de Slack dispara (paso 2, duradero). La función se suspende por hasta 24 horas.
  • Un humano en Slack hace clic en Aprobar o Rechazar. El backend de admin 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 coincidan los IDs de solicitud que correspondan). La función se reanuda en la siguiente línea.
  • La función usa la decisión del humano para emitir el reembolso o notificar el rechazo. Ambos caminos auditan la decisión y al aprobador.

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 el gate en línea. No hay callback, no hay restauración de estado, no hay despacho de if state == waiting_for_approval: .... El runtime maneja el mecanismo de suspender/reanudar; tu código expresa la política.

El gate de aprobación humana en runtime, en tres fases. Fase 1: el agente corre, investiga, redacta la acción, y pide aprobación, luego pausa. Fase 2: la función se suspende en step.wait_for_event a cero cómputo por el tiempo que tome, mientras un revisor humano lo lee en Slack o una interfaz de admin y hace clic en Aprobar o Rechazar. Fase 3: un evento de aprobación reanuda la función, que se ramifica: Aprobar emite el reembolso, Rechazar 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.

El agente propone, una persona decide, y la espera no cuesta nada.

Un curso posterior desarrolla el Invariante 1 arquitectónicamente: la intención autorizada, los flujos de trabajo dirigidos por especificación, la capa de manager-de-workers que decide qué gates aplican a qué acciones. Este curso te da la primitiva del runtime. Cuando esa capa de manager llegue, el gate que implemente será exactamente este patrón de wait_for_event, solo compuesto a escala de flota. Conocer la primitiva ahora significa que el patrón arquitectónico después se lea como "una composición sensata" en lugar de "magia".

Esta es la piedra angular 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 dispara exactamente una vez.

Predice. Tienes un gate HITL fijado 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 el fin de semana. El timeout del gate dispara a las 17:00 del sábado. Tu manejador de timeout registra un reembolso bloqueado. El revisor lee la solicitud el lunes a las 9:00am. 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 estuvo suspendida: Inngest almacenó su estado, sacó la función de memoria, y esperó por el evento o el timeout. Inngest no factura por el tiempo suspendido. Cuando llegaron las 17:00 del sábado y disparó el timeout, la función se reanudó por los pocos cientos de milisegundos que tomó escribir la fila de auditoría del reembolso bloqueado, luego se completó. El hecho de que el revisor no mire hasta el lunes no cuesta nada desde el lado del worker. La economía de los flujos de trabajo HITL en Inngest es dramáticamente distinta de las colas basadas en polling que te facturan cada segundo 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 Worker de IA de soporte al cliente

Esta es la columna vertebral del curso: donde de verdad construyes. Todo lo anterior fue el modelo y la referencia. De aquí en adelante ensamblas el worker real. Primero el worker (un prompt), luego el sistema nervioso a su alrededor, una capa por prompt. Cada capa nombra el concepto del que se nutre, así que si una capa plantea un "por qué", ese concepto en las Partes 1 a 3 es la página a abrir. Diriges a tu agente general con prompts cortos en lenguaje natural y él escribe el código. Los snippets de abajo son las pocas líneas que cargan el peso de cada capa, no los archivos. La implementación completa se ejecutó de extremo a extremo contra un dev server en vivo y un modelo real, así que las formas que ves son las que corren. Si una firma luce desconocida, tu agente consulta la documentación actual.

El flujo completo que estás a punto de construir, un correo de extremo a extremo:

  a customer emails
|
v
the INNGEST ENGINE catches the event and drives your worker,
one step at a time, storing each result as it goes:

1. audit: "message received"
2. load the customer from Neon
3. YOUR AGENT drafts a reply (the thinking part; D1 makes it durable)
4. is it a refund? PAUSE for a human (waits hours, survives crashes; D5)
5. on approve: issue the refund; on reject: record it
6. audit: "reply sent"

if a step crashes, the engine re-runs only that step, never the
finished ones (D6). the same worker also wakes on a daily cron
and runs under flow-control caps (D3, D4).

La misma imagen de dos programas de la apertura, el motor conduciendo tu agente, ahora el worker real. Lo construyes una capa a la vez:

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

  • D0 construye el worker en sí, autónomo.
  • D1 hace duradera la ejecución del agente.
  • D2 deja que un evento lo despierte.
  • D3 agrega un cron diario que hace fan-out.
  • D4 agrega control de flujo.
  • D5 es la piedra angular: un gate de aprobación humana duradero sobre los reembolsos.
  • D6 prueba que el worker sobrevive a un paso roto: reintento sin rehacer el trabajo completado, luego recuperación.

La Parte 4 construye el sistema nervioso una capa a la vez. D0 (izquierda) es el agente, construido una vez: piensa y actúa, con el OpenAI Agents SDK, dos herramientas, Neon Postgres y una traza de auditoría; nunca cambia 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 ante 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 Gate (la piedra angular, un gate de aprobación duradero donde la mente vuelve a entrar), y D6 Prueba (rompe un paso y recupera). Los sentidos lo despiertan, los reflejos lo mantienen correcto, el equilibrio lo mantiene sano, y el gate deja que un humano decida.

El agente nunca cambia después de D0; cada capa es el sistema nervioso, agregado desde afuera.

Antes de empezar. Tu entorno ya está montado desde la Quick Win: abre la misma carpeta ai-agent-nervous-system, con las Skills 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) cableados. Solo dos recordatorios:

  • El dev server está corriendo. Arráncalo de nuevo si lo cerraste: npx inngest-cli@latest dev en su propia terminal. El panel está en http://127.0.0.1:8288. (Cuando después 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 MCP de abajo. Los nombres de las herramientas del dev server 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 MCP son camelCase.

El encargo

Construyes un pequeño worker de soporte al cliente y le das un sistema nervioso. 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 de nombres de acción que elige (un conjunto cerrado, para que un error de tipeo se vuelva un error ruidoso en lugar de una fila mala silenciosa). 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 cliente elegible, el control de flujo limita la concurrencia y el throttle, el reembolso pausa en un gate humano duradero, y un camino de replay recupera ejecuciones fallidas.

Una nota sobre los prompts que siguen. Cada uno está escrito de la forma en que de verdad se lo dirías a un agente general: corto, llano, 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 poco 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, autónomo

Dónde estás: la base está abierta, el dev server está corriendo, y tu almacén Neon está aprovisionado, pero aún no existe ningún worker. Esta Decisión construye el worker autónomo; 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 quedan 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 deja intercambiar Inngest por Temporal o Restate después y dejar el worker intacto.

Tu sistema de registro de Neon ya está aprovisionado desde la Quick Win: 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 principio. Ahora construye el worker. Pega esto:

Constrúyeme un agente de soporte 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 cliente, y puede emitir un reembolso, pero la herramienta de reembolso necesita aprobación humana antes de correr. Cuando un correo reporta un cargo duplicado, un sobrecargo, o un pedido fallido, el agente debe de verdad llamar a la herramienta de reembolso, no solo prometer un reembolso en prosa. 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 el 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 ser entregado. Luego córrelo sobre un correo de muestra y muéstrame la respuesta.

El worker alcanza Postgres por DATABASE_URL, nunca por el MCP de Neon (esa es tu herramienta de tiempo de construcción solamente). Una línea de lo que el agente escribe carga el peso 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:
...

needs_approval=True hace que el agente pause en lugar de emitir el reembolso: la ejecución vuelve con el reembolso pendiente para que un humano decida. Es el gancho del que cuelga la piedra angular de D5. (Este piso pone gate a cada reembolso para mantener simple la piedra angular; en producción pondrías gate solo por encima de un umbral, el patrón de más-de-$100 del Concepto 15. Mismo cableado.) Una cosa a mantener factorizada, porque D5 se apoya en ella: construye el agente y su configuración de ejecución del sandbox como piezas separadas, para que D5 pueda reconstruir el agente y volver a suministrar el sandbox en la reanudación.

Listo cuando: el agente corre sobre un correo de muestra e imprime una respuesta corta, y hay una fila nueva 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 pausa en la herramienta de reembolso en lugar de emitirlo; esa pausa es todo el punto, y D5 la hace duradera.

El modelo de tu agente general importa aquí

Los prompts de esta Parte suponen un agente general 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 a nivel de 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, en especial la piedra angular de D5. Con 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 duradera la ejecución del agente

Dónde estás: un worker que corre solo cuando lo llamas, perdiendo todo en una caída 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 caídas y reintente fallos transitorios. Toda la llamada al agente va dentro de un solo step.run para que quede memoizada. Córrela en modo dev local contra el dev server de Inngest, con un host de FastAPI. Confirma que una ejecución completada muestra el paso del agente memoizado en el panel.

La llamada al agente es la parte cara (tokens del modelo, varios segundos). Dentro de step.run su resultado queda memoizado, así que cuando un paso posterior falla y la ejecución reintenta, el agente no vuelve a correr. Esa es la diferencia entre un worker que vuelve a pagar y a actuar en cada reintento y uno que hace cada cosa cara una vez. Mantén al agente invocado con una ejecución simple (no en streaming); la reanudación duradera de D5 se construye sobre ella.

Corre como dos procesos: el host de FastAPI, y el dev server de Inngest apuntado a él. Tu agente arranca ambos.

Listo cuando: el panel lista la función y una ejecución completada muestra el paso del agente. (Lo despiertas con un evento real en D2; por ahora, que sea descubrible es suficiente.)


D2: dispáralo ante un evento

Dónde estás: la función duradera existe, pero aún la disparas a mano y no se registra nada. Esta Decisión la despierta ante un evento real y escribe una fila de auditoría a cada lado del agente.

Esta es la primera vez que la imagen de la apertura corre de verdad. En lugar de que tú llames al worker, llega un evento customer/email.received, el motor lo captura, y el motor llama a tu worker para correr. También empiezas a registrar qué pasó: una fila de auditoría justo antes del agente, una justo después. Pega esto:

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

Para probarlo localmente, envía el evento tú mismo con el send_event del MCP del dev server (un evento customer/email.received que lleva el texto del correo y el id del cliente), sin webhook. En producción apuntarías en cambio tu proveedor de correo a una URL de webhook de Inngest, que es un ajuste del panel, no código.

Listo cuando: un evento de prueba conduce una ejecución que se completa con tres pasos en orden (auditoría, agente, auditoría) y dos filas nuevas en la tabla audit_log de Neon, una antes del agente y una después.

Por qué dos pasos, no uno. Cada escritura de auditoría es su propio step.run, así que cada una se memoiza por su cuenta. Si el paso de respuesta falla y la ejecución reintenta, la fila de ingreso no se escribe dos veces y el agente no corre dos veces, así que la traza de auditoría se queda exactamente-una-vez a través de los reintentos (la propiedad que D6 prueba).


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 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 cliente elegible.

Dos cosas cargan esta Decisión. El fan-out va dentro de un paso (step.send_event, no un send directo del cliente), para que un reintento del cron no vuelva a emitir duplicados. Y cada evento obtiene un id de idempotencia derivado del cliente y del tic del cron (algo como health-{customer}-{cron_run}): si el mismo tic se entrega dos veces (un redespliegue, un reintento), el duplicado se descarta, así que cada cliente obtiene exactamente una comprobación ese día. Invoca el cron desde tu agente con el invoke_function del MCP (no esperes a las 09:00). Una particularidad de dev: el dev server 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 por cliente elegible, con los clientes de tier estándar correctamente omitidos.

Por qué fan-out, no un bucle. El padre no procesa él mismo a los clientes; 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 detiene al resto, y una caída pierde el lote entero. El fan-out es cómo un despertar programado se vuelve N ejecuciones duraderas independientes.


D4: control de flujo

Da un paso atrás primero: a estas alturas has ensamblado un worker, alcanzado de tres formas, todas compartiendo un almacén Neon. Esto es a lo que D4 le pone límites.

              INNGEST ENGINE   (routes events, runs functions, stores steps)
|
┌──────────────┼────────────────┐
v v v
an email a daily cron one run per customer
arrives fans out a the cron emitted
(D2: the check per (D3: each isolated,
email worker) customer (D3) retryable on its own)
└────────── all run in YOUR host ───────────┘
|
Neon Postgres (customers + audit_log)

El mismo agente dentro de cada camino; solo difiere cómo el mundo lo alcanza. Ahora mantienes todo eso sano bajo carga.

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 hace cola bajo el límite sin filas descartadas ni duplicadas.

Cuando quinientos correos aterrizan a las 9am, el worker no debería disparar quinientas llamadas al modelo a la vez: eso rebasa el límite de tasa y deja sin recursos a todos detrás del cliente ruidoso. Agrega un límite global de concurrencia, un límite por cliente, y un throttle. Pega esto:

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

Tres perillas hacen tres trabajos: un límite global de concurrencia (cuántas ejecuciones corren a la vez), una clave de concurrencia por cliente (para que una cuenta ruidosa tome como mucho una ranura o dos y nunca deje sin recursos al resto), y un throttle (cuántas ejecuciones arrancan por minuto). Empareja el throttle a tu límite real aguas abajo: el límite de OpenAI del encargo es de unos 30 por minuto, así que 30, no un genérico 100. (Una función lleva como mucho dos políticas de concurrencia; el par global-más-por-clave es la forma común.)

El límite de concurrencia protege dos techos: el límite de tasa del modelo y tu presupuesto de conexiones de Neon. Una sola copia en ejecución de tu worker ya mantiene limitadas sus propias conexiones de base de datos, porque cada ejecución en ella comparte un pool de conexiones. El límite de concurrencia es lo que mantiene sano el total una vez que corres varias copias a la vez: diez copias a un límite de 10 cada una son unas 100 conexiones, que dimensionas contra el presupuesto de Neon. El pool acota una copia; el límite acota la flota.

Dispara la ráfaga desde tu agente: veinte eventos customer/email.received a través de cinco clientes vía send_event.

Listo cuando: la ráfaga hace cola bajo el límite (el conteo en ejecución se queda en o por debajo del límite global, y en o por debajo del límite por cliente), cada ejecución se completa, y la traza de auditoría tiene exactamente una fila de entrada y una de salida por evento, sin ejecuciones descartadas, sin duplicados, y sin errores de conexión de Neon.

Por qué estas son política, no código. Nada de esto vive en el cuerpo de tu función; es configuración que el runtime impone. Sin los límites, una ráfaga o derrite un sistema aguas abajo o deja que un inquilino monopolice el worker. Escribir la misma equidad a mano es una cola más un programador más un limitador de tasa, cientos de líneas. Aquí son tres argumentos de decorador.


D5: un gate de aprobación humana duradero sobre los reembolsos (la piedra angular)

Dónde estás: allá en D0 tu agente ya pausa antes de un reembolso, pero esa pausa solo vive en memoria. Esta Decisión la hace sobrevivir a una caída, un despliegue, o un revisor que tarda horas, para que el reembolso igual dispare exactamente una vez cuando finalmente aprueben.

Aquí está toda la idea antes de cualquier código. Tu agente decide que un reembolso está justificado, pero no debe emitirlo hasta que un humano diga que sí. La pausa de D0 mantiene esa decisión solo en el proceso en ejecución, así que una caída o un revisor lento la pierden. D5 convierte esa pausa en una espera duradera: la función se va a dormir (sin costar nada) y solo despierta cuando llega la decisión.

  the agent decides a refund is warranted
|
v
it PAUSES and asks a human (it does NOT issue the refund yet)
|
v
the function SLEEPS, waiting for the decision
(minutes or hours; free while it waits; survives a crash,
a deploy, a reviewer who goes to lunch)
|
v
a human clicks Approve or Reject -> sends the decision event
|
v
the function WAKES and finishes:
approved -> issue the refund (exactly once)
rejected -> no refund; record it
no answer in 4h -> no refund; record a timeout

Pega esto:

Ahora mismo el agente pausa antes de un reembolso, pero esa pausa se pierde si el worker se cae o el revisor tarda horas. Haz que la pausa sobreviva a eso: cuando el agente se detenga por aprobación, guarda dónde se detuvo, luego espera hasta cuatro horas por un aprobar-o-rechazar de un humano para este cliente. Cuando la decisión llegue, retoma exactamente donde el agente quedó y termina, para que el reembolso ocurra como mucho una vez por ejecución. Ante un rechazo, la respuesta al cliente debe decir que el reembolso fue denegado, nunca que fue emitido. Luego pruébamelo: conduce un reembolso, muestra la ejecución esperando, envía una aprobación, y muestra exactamente una fila de reembolso. Hazlo de nuevo con un rechazo y muestra una fila bloqueada y ningún reembolso.

Toda esa imagen es una línea de código. La función se detiene en wait_for_event y arranca de nuevo cuando aparece el evento de decisión:

decision = await ctx.step.wait_for_event(
"await-refund-approval",
event="refund/approval.decided", # what we are waiting for
timeout=datetime.timedelta(hours=4), # give up after 4 hours
if_exp=f"async.data.customer_id == '{customer_id}'", # only THIS customer's decision
)

# no decision came in 4 hours -> write a blocked-refund row and stop
# approved or rejected -> pick the agent back up and finish

Esa única llamada es todo el gate. No escribes ninguna cola, ningún bucle de polling, ni ningún indicador de "¿ya está aprobado?" que comprobar a mano. El runtime mantiene la pausa por ti. Tu código solo dice qué esperar y qué hacer con la respuesta. Tres cosas son fáciles de equivocar, sin embargo, y cada una rompe el gate en silencio:

  • if_exp correlaciona la decisión con este cliente, así que una aprobación para un cliente nunca reanuda la ejecución de otro. customer_id sirve aquí porque la demo tiene como mucho un reembolso pendiente por cliente; si un cliente pudiera tener dos reembolsos en vuelo a la vez, correlaciona sobre un request_id único (la clave que usan los Conceptos 8 y 15) o el id de ejecución en su lugar, o una aprobación podría reanudar la ejecución equivocada.
  • Cuando el agente se reanuda, devuélvele el estado que guardaste, no una conversación nueva. Aquí está lo que sale mal si lo olvidas: una conversación nueva no recuerda que ya pidió aprobación, así que el agente reanudado golpea el reembolso de nuevo, pide aprobación de nuevo, y entra en bucle para siempre. Reconstruye el agente y vuelve a suministrar su configuración de ejecución, luego aliméntalo solo con el estado guardado. (Por esto D0 mantuvo factorizados aparte la construcción del agente y su configuración de ejecución; es el único detalle que, omitido, hace fallar la reanudación.)
  • Guardar el estado descarta calladamente tu contexto personalizado, así que vuelve a ponerlo a mano. Esta es la trampa que falla sin un error. Cuando el Agents SDK serializa la ejecución pausada, no traslada un contexto de ejecución personalizado (el objeto del que tu herramienta de reembolso lee el id del cliente y la clave de idempotencia); guarda uno vacío y solo advierte. Así que en la reanudación debes volver a suministrar ese contexto tú mismo, con RunState.from_string(agent, saved_state, context_override=your_context). Omítelo y la herramienta de reembolso aprobada corre sin contexto: calladamente no escribe ninguna fila de reembolso, mientras la ejecución igual reporta éxito. Ves "aprobado, pero ninguna fila refund_issued" y nada que lo explique. (Verificado en openai-agents 0.17.x; las reglas exactas de serialización son el tipo de detalle beta que cambia entre versiones menores, así que confírmalo contra la documentación de run-state del Agents SDK cuando construyas.)

Condúcelo desde tu agente: envía un evento customer/email.received que describa un reembolso, observa la ejecución suspenderse en el gate (el panel la muestra en WAITING a cero cómputo), luego haz send_event de un refund/approval.decided que lleve {"approved": true, ...} para ese cliente. Hazlo de nuevo con {"approved": false}.

Listo cuando: ante una aprobación, la ejecución suspendida se reanuda y la tabla audit_log de Neon tiene exactamente una fila refund_issued. Ante un rechazo, 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.

El gate te da exactamente-una-vez dentro de una sola ejecución, y vale la pena enunciar el límite. Si el mismo reembolso se conduce a través de dos ejecuciones (un evento reenviado, un replay manual), nada aquí evita un segundo reembolso por sí mismo; ese es el trabajo de la clave de idempotencia estable del Concepto 4 (o la propia clave del proveedor), indexada por la solicitud, exactamente como mostró el ejemplo de reembolso de allí. El worker mínimo deja esa clave fuera para mantenerse pequeño, así que prueba "exactamente una vez" contra una ejecución, y recurre a la clave del Concepto 4 en cuanto un reembolso real pudiera conducirse dos veces.

Por qué esta es la piedra angular. Cada otra capa (los sentidos, los reflejos, el equilibrio) mantiene al worker correcto o sano por su cuenta. 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 tome.


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 esto; al final has visto una ejecución rota reintentar 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, el mecanismo de memoización del Concepto 7. Lo entendiste allí; ahora pruébalo 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 queda memoizado, para que la ejecución que falla escriba su fila de auditoría de ingreso 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, dispara unos pocos eventos customer/email.received, y lee la traza de cada ejecución. La prueba está dentro de cada ejecución fallida: el paso de auditoría de ingreso muestra un intento completado (su fila escrita una vez) mientras el paso del agente muestra varios intentos a medida que reintenta con backoff y luego falla, y el paso de respuesta nunca corre. El paso de auditoría en un intento mientras el paso del agente sube es la memoización del Concepto 7, ahora en tu propio worker: la ejecución que falla escribe su fila de ingreso una vez, sin importar cuántas veces reintente el agente.

Luego revierte la rotura y recupera el trabajo volviendo a disparar el evento sobre el código arreglado (o, para un lote real de mal despliegue, el botón Rerun del panel; ambos inician una ejecución nueva desde el principio, Concepto 14). Aquí está la parte que sorprende a la gente, y es correcta, no un bug: la recuperación es una ejecución totalmente nueva, así que escribe su propia fila de ingreso. Tras un romper-y-recuperar, ese cliente legítimamente tiene dos filas de ingreso, 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, el paso de ingreso se quedó en un intento y escribió una fila mientras el paso del agente acumuló varios intentos y falló (ese un-intento-a-pesar-de-N-reintentos es la memoización), y la ejecución de recuperación luego se completa sobre el código arreglado. El diagnóstico es por ejecución, no por cliente: abre la traza de una sola ejecución y confirma que el paso de ingreso muestra un intento. Dos filas de ingreso a través de dos ejecuciones separadas es correcto; el paso de ingreso corriendo dos veces dentro de una ejecución sería el bug (normalmente un nombre de paso no único).

Por qué esta es la línea divisoria. Un worker que pierde el trabajo del cliente en un mal despliegue es solo un agente al que llamas. Un worker que toma el mismo mal despliegue, falla ruidosamente, reintenta el paso roto sin rehacer el trabajo que ya terminó, y se recupera limpio en una ejecución nueva tras el arreglo, es un Worker de IA.

¿Hiciste el curso de 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 run-state hecha a mano de la Decisión 10 opcional de ese curso: el gate duradero que acabas de construir es la capa de persistencia, así que puedes borrar la tabla.


Lo que acaba de pasar

Construiste un pequeño worker de soporte al cliente y le diste un sistema nervioso, una capa a la vez. Las entrañas del worker nunca cambiaron después de D0: el mismo SandboxAgent, las mismas dos herramientas, la misma traza de auditoría en Neon Postgres. Lo que cambió es todo lo que está alrededor de él. Ahora despierta ante un evento customer/email.received y ante un cron diario que hace fan-out por 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 gate a los reembolsos sobre una aprobación humana duradera (step.wait_for_event), y se recupera de un mal despliegue reproduciendo ejecuciones fallidas, con la traza de auditoría mostrando que dentro de cualquier ejecución cada paso 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 operas, lo prompteas, lo observas, lo prompteas de nuevo. Ahora tienes un worker que opera por su cuenta: 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 lo exige el riesgo. Esa es la línea que trazó la apertura, entre un agente que operas y un FTE que opera por su cuenta, y acabas de construir cruzándola.

Las preocupaciones restantes son la observabilidad a escala, la coordinación multi-worker, y la capa de manager que decide qué workers manejan qué tráfico. Esos son cursos que todavía vienen más adelante en la ruta. Este curso cubre la unidad de ejecución lista para producción; los cursos de fuerza de trabajo componen esas unidades en una fuerza de trabajo.


Parte 5: dónde termina este curso

La forma de costo de un Worker de IA

Importan dos superficies de costo: el costo de infraestructura (Inngest, y el almacén y cómputo donde sea que corras el worker) y el costo de inferencia (tokens del modelo). La infraestructura se queda más o menos plana a medida que sube la carga; la inferencia escala linealmente. El método de abajo es lo que hay que aprender; cualquier cifra en dólares queda 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 un número 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
Enterprisepersonalizadopersonalizado500-50.000SAML / RBAC, retención de trazas de 90 días, soporte dedicado

Nota que Inngest mide dos cosas distintas. Una son las ejecuciones (la tabla de arriba): una ejecución de función más cada reintento de paso. La otra son los eventos (lo que envías): los primeros 1-5M de eventos por día están incluidos, y por encima de eso el excedente empieza alrededor de $0,000050 por evento y baja a mayor volumen. En Pro, pasarse del límite de 1M de ejecuciones agrega $50 por cada 1M de ejecuciones adicional.

Techos del nivel Hobby que importan aquí. El límite de 5 pasos concurrentes significa que aunque declares concurrency=Concurrency(limit=10) en el código, el límite a nivel de cuenta de la plataforma te sostiene 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 los planes de pago (límites de uso de Inngest).

El costo de inferencia domina. Una ejecución típica de soporte 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 cacheado estable (para que el modelo facture la parte repetida a la tarifa cacheada más barata, 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 los caminos masivos. 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 por 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 quedan comparativamente planos. Corre la misma multiplicación a 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 a 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 conforme. Cinco intercambios que el diseño del curso anticipa explícitamente:

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

  • Ejecución duradera: step.run de Inngest → activities de Temporal, handlers de Restate, máquinas de estados personalizadas respaldadas por Postgres. Cada una te da la semántica de "memoiza esta llamada con efecto secundario, reintenta ante fallo transitorio, reanuda tras caída". Temporal es el análogo más cercano y la opción más vieja y 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 1.000 a 10.000 líneas de código que recrean ~70% de lo que Inngest te da gratis.

  • Primitiva HITL: step.wait_for_event → el await Workflow.execute_activity(approval_signal) de Temporal, los 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 de escribir; la de Temporal es más verbosa pero probada en batalla a gran escala.

  • Programación de cron: triggers de cron de Inngest → CronJobs de Kubernetes + cola, programaciones de GitHub Actions, programaciones de AWS EventBridge. Los triggers de cron son un commodity. 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 tasa respaldados por Redis, timeouts 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 de 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 forma en que OpenCode lo es de Claude Code. Dapr Agents alcanzó v1.0 GA el 23 de marzo de 2026 bajo gobernanza de CNCF (anuncio de CNCF, conceptos básicos de Dapr Agents). DurableAgent es la clase lista para producción; la clase Agent más vieja está obsoleta. Elige Dapr cuando el despliegue nativo de Kubernetes y los SDK multilenguaje importen más que la experiencia de dev 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 tocado los techos de nivel de Inngest o necesitas un 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 autoalojamiento en septiembre de 2024) y autoalojable vía Helm + KEDA. Los ejes que importan a escala son la gobernanza, el soporte, y la madurez: Inngest está gobernado por un solo proveedor con una historia de autoalojamiento joven; Dapr está gobernado por CNCF con un historial de producción más largo.

Concepto de este cursoPrimitiva de InngestAnálogo de producción de DaprNota didáctica
Trabajo programadoTriggerCronBinding de entrada de cron / Programador de DaprMisma idea: el tiempo despierta al worker. Dapr suele requerir configuración de componentes.
Ingreso de webhook/eventoendpoint de webhook de Inngest → eventoendpoint HTTP, bindings de entrada, o ingreso por pub/subInngest oculta más plomería; Dapr da control de infraestructura.
Eventos internosinngest_client.send()pub/sub de DaprMismo modelo mental orientado a eventos; el broker es enchufable 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 + activitiesPropósito de producción similar, modelo de desarrollador distinto.
Esperar sin cómputostep.sleep()temporizadores de workflow duraderoAmbos evitan mantener un proceso abierto mientras esperan.
Gate de aprobación humanastep.wait_for_event()eventos/señales externos 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/activity + 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, batchingEscalado de Kubernetes, concurrencia de app, controles de broker, políticas de resiliencia, pub/sub masivoDapr puede hacerlo, pero no es un argumento de decorador. Inngest es más denso.
Coordinación con estadowait_for_event, claves de evento, estado de pasoActores + almacén de estado + workflowsLos actores de Dapr son más fuertes para la 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 explícitamente hace al agente respaldado por workflow y 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 desarrollador compacta: triggers, pasos, esperas, replay, y control de flujo en una sola superficie de producto. Dapr implementa la misma arquitectura de producción a través de 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 básicos de Dapr Agents a mayo de 2026.

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

  • Gobernado por CNCF, neutral respecto a proveedores por carta: ningún proveedor solo controla la plataforma ni tu dependencia de ella.
  • Polígloto con Python de primera clase. Dapr Agents es Python primero; 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 salvedad 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 meta sigue siendo aprender los patrones, que es por qué este curso empieza en Inngest: un comando, y el panel aparece. Recurre a Dapr una vez que los patrones hayan aterrizado y la pregunta cambie a correr a escala organizacional sobre infraestructura que controlas.

Aprende los conceptos en Inngest y el OpenAI Agents SDK primero: bucle 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 de proveedor se vuelvan 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 que carga el peso.


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, la traza de auditoría), con el mundo capaz de llamarlo (Invariante 7, los triggers que agregaste), y con el humano como principal en una decisión con gate (Invariante 1, parcial: el mecanismo de runtime está aquí, el patrón arquitectónico más amplio es para después). Los tres Invariantes restantes, y la arquitectura más amplia que hace una fuerza de trabajo a partir de workers, son cursos posteriores. Un punto 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 de trabajo. La tesis nombra a OpenClaw como la realización actual.
  • Invariante 3: la fuerza de trabajo necesita un manager. Un orquestador que asigna trabajo, impone presupuestos, audita la ejecución, expone la contratación como una capacidad invocable. La tesis nombra a Paperclip.
  • Invariante 6: la fuerza de trabajo es expandible bajo política. Una meta-capa 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 ante eventos, corre de forma duradera, y pone gate ante humanos es la unidad más pequeña de la arquitectura que enseña este curso. Los cursos que vienen extienden ese worker en una fuerza de trabajo: múltiples workers coordinados por un manager, expandibles a demanda, despertados por triggers, 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 bueno en esto de verdad

Leer este curso intensivo no te hace bueno construyendo Workers de IA. 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 dispara cuando llega el evento?" → error de tipeo en el nombre del evento o desajuste de espacio de nombres (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 disparó dos veces para el mismo evento lógico?" → falta la 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 el throttle (Concepto 11). Agrega throttle=Throttle(limit=N, period=timedelta(minutes=1)).
  • "¿Por qué las ráfagas de un cliente dejan sin recursos a otros clientes?" → falta la concurrencia por clave (Concepto 12). Agrega un segundo Concurrency(limit=2, key="event.data.customer_id").
  • "¿Por qué mi gate HITL disparó en silencio el fin de semana?" → falta el manejador de timeout que escribe en auditoría (Concepto 15). Ramifica sobre 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 deliberadamente lo caes a mitad de ejecución. Despiértalo ante un evento (D2). Agrega el fan-out del cron (D3), luego el control de flujo (D4) una vez que de verdad hayas tocado un límite de tasa, luego el gate de aprobación duradero (D5) cuando una acción de alto riesgo de verdad necesite un humano. Cada capa es su propio aprendizaje. Combinadas en una sola reescritura grande, son un muro.

La disciplina que enseña este curso (despertar ante eventos, correr de forma duradera, poner gate ante humanos, reproducir ante bugs) es el invariante arquitectónico. Sea cual sea la plataforma que lo implemente, ese contrato de cuatro propiedades es a lo que de verdad te comprometes. 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 pista de una línea de cada concepto está en la hoja de trucos colapsada de la introducción; esta sección es el diagnóstico durante la construcción, los dos árboles de decisión, y la disposición de archivos.

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

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

  • Un sistema externo nos envió una solicitud HTTP. → Trigger de webhook. Configura el origen en el panel de Inngest; reformatea el payload vía el transform; consume el evento resultante.
  • Una programación dice que es hora. → Trigger de cron. TriggerCron(cron="..."). Usa UTC; los crons de producción disparan incluso cuando tu servicio está a mitad de despliegue.
  • Otra función de Inngest emitió un evento durante su ejecución. → Trigger 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 trigger de Inngest. Mantén la solicitud/respuesta en tu endpoint web normal; si la respuesta involucra 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, ¿qué llamada step.* usas?

  • Una llamada con efecto secundario (API, BD, escritura de archivo, invocación de agente).ctx.step.run("name", fn, ...). La opción por defecto. Memoizada al tener éxito, reintentada ante fallo transitorio.
  • Una llamada larga a OpenAI en una plataforma sin servidor que cobra por el tiempo en vuelo.ctx.step.ai.infer(...). Descarga la inferencia a la infraestructura de Inngest para que el proceso de tu función pueda liberarse.
  • Esperar una duración fija antes de continuar.ctx.step.sleep("name", timedelta(...)). Duradero; cero cómputo mientras espera (hasta siete días en el plan gratuito, un año en los 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=...). Duradero; se reanuda cuando llega el evento o devuelve None al agotar el tiempo.
  • Cómputo determinista puro (formatear una cadena, computar 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)

Estos nombres de archivo son una disposición sensata, no un requisito; tu agente podría aterrizar en agent.py y main.py en cambio, y eso está bien. Lo que importa es el límite, no los nombres: el código del worker nunca importa inngest, y exactamente un archivo cablea el sistema nervioso encima. Con esa disposición, los clientes y la traza de auditoría viven en tu base de datos Neon (aprovisionada en la Quick Win, sembrada en D0), no en archivos locales; los archivos del worker nunca cambian después de D0, y cada capa del sistema nervioso (D1 a D5) edita el único archivo de Inngest.

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

SíntomaPrimer sospechosoConcepto a releer
La función nunca dispara cuando llega el evento esperadoError de tipeo en el nombre del evento, desajuste de espacio de nombresC3 (webhooks), C5 (fan-out)
La función dispara dos veces para el mismo evento lógicoFalta la clave de idempotenciaC4 (idempotencia)
La función "perdió trabajo" tras un despliegueCódigo fuera de step.run haciendo el trabajoC7 (memoización)
La programación de cron no disparó durante un despliegueSolo dev server local, producción corre en infraestructura de InngestC2 (cron)
Cliente cobrado 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 tasa de OpenAI durante el pico de las 9amFalta el throttleC11 (concurrencia + throttle)
Las ráfagas de un cliente dejan sin recursos a otros clientesFalta la concurrencia por claveC12 (prioridad + equidad)
Función suspendida para siempre, nunca reanudadaEl nombre de evento en wait_for_event no coincide con el evento que se envíaC8 (wait_for_event), C15 (HITL)
El timeout HITL disparó en silencio el fin de semanaFalta el manejador de timeout que escribe en auditoríaD5 (gate de reembolso duradero), C15 (HITL)
Las ejecuciones fallidas de ayer desaparecieron del panelLas ejecuciones persisten hasta ser reproducidas 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 cobro 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 paso muestra entradas/salidas de la función pero no telemetría de prompt/token específica del LLMC10 (Python usa step.run; la telemetría específica del LLM necesita tu propio trazado 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 de trucos de Inngest

No necesitas el curso de Digital FTE para hacer la Parte 4: D0 construye el worker desde cero. Dos notas breves de contexto.

A.1: si vienes del curso de Digital FTE

El curso De agente a Digital FTE construye un worker de soporte al cliente más rico: Skills portátiles, un sistema de registro en 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. El envoltorio es idéntico. Un bono: el gate de reembolso duradero que construyes en D5 (step.wait_for_event) reemplaza la tabla de run-state hecha a mano de la Decisión 10 opcional de ese curso, así que puedes borrarla. 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 abajo te resulta desconocido, hojea la página de documentación correspondiente antes de zambullirte en la Parte 4.

  • Instanciación del cliente de Inngest. Una sola instancia inngest.Inngest(app_id=...) por proyecto de Python, exportada de un módulo e importada donde sea que decores funciones. Inicio rápido de Python.
  • Decoración de funciones. @inngest_client.create_function(fn_id=..., trigger=...). El trigger puede ser TriggerEvent, TriggerCron, o una lista de ambos para funciones multi-trigger.
  • ctx.step.run, ctx.step.sleep, ctx.step.wait_for_event, ctx.step.ai.infer. Las cuatro primitivas de paso que constituyen el 90% de lo que escribirás en Python. (TypeScript tiene una quinta, step.ai.wrap, para trazado específico del LLM; los proyectos de Python usan step.run para las 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 dev server. 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.

Material de estudio: flashcards

Comprobación de conocimientos

Una autoevaluación rápida con gate sobre las ideas que acabas de recorrer.

Checking access...