> ## Documentation Index
> Fetch the complete documentation index at: https://docs.jelou.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Ejecuciones diferidas

> Programa una ejecución futura de tu función: recordatorios, seguimientos y carritos abandonados con ctx.jelou.schedule, cancelación y guards de disparo.

Una **ejecución diferida** es una invocación que agendas para que ocurra una sola vez en el futuro: un recordatorio a las 24 horas, un seguimiento de carrito abandonado, una encuesta 2 días después de la compra.

<Info>
  **¿Cron o diferida?**

  * **[Cron](/guides/functions/cron)** — se repite en un horario fijo (todos los días a las 9:00). Se define en el código.
  * **Diferida** — ocurre una vez, en un momento calculado en tiempo de ejecución (24 horas después de *este* pedido). Se agenda desde el handler o desde la petición HTTP.
</Info>

## Agendar desde el handler

`ctx.jelou.schedule()` agenda una ejecución relativa a "ahora":

```typescript index.ts theme={null}
import { define, z } from "@jelou/functions";

export default define({
  name: "carrito-abandonado",
  description: "Recibe el evento de carrito abandonado y agenda el seguimiento",
  input: z.object({
    userId: z.string(),
    carritoId: z.string(),
  }),
  handler: async (input, ctx) => {
    const booking = await ctx.jelou.schedule({
      in: "24h",
      path: "/enviar-seguimiento",
      // Incluye el botId: en el disparo no hay canal resuelto
      payload: { userId: input.userId, carritoId: input.carritoId, botId: ctx.bot.id },
      key: `carrito:${input.userId}:${input.carritoId}`,
      subject: `user_${input.userId}`,
    });

    ctx.log("Seguimiento agendado", { id: booking.id, cuando: booking.scheduledAt });
    return { agendado: true, id: booking.id };
  },
});
```

### Parámetros

| Campo            | Tipo               | Requerido | Descripción                                              |
| ---------------- | ------------------ | --------- | -------------------------------------------------------- |
| `in`             | `string \| number` | Sí        | Duración: `"30m"`, `"24h"`, `"1h30m"`, `"3d"` o segundos |
| `path`           | `string`           | Sí        | Ruta de tu función que se ejecutará. Empieza con `/`     |
| `payload`        | `object \| array`  | No        | Datos que recibirá el handler al dispararse              |
| `key`            | `string`           | No        | Etiqueta para buscar o cancelar después                  |
| `subject`        | `string`           | No        | Audiencia, normalmente el ID del usuario                 |
| `idempotencyKey` | `string`           | No        | Evita agendar dos veces si reintentas                    |
| `pinDeployment`  | `boolean`          | No        | Fija la ejecución al despliegue actual                   |

La duración acepta unidades compuestas de mayor a menor: `"1h30m"` es válido, `"30m1h"` no.

<Warning>
  Para seguimientos muy cortos usa **5 segundos o más**. Con duraciones menores la latencia de red puede dejar el momento agendado en el pasado y la plataforma lo rechaza.
</Warning>

### Momento absoluto

`ctx.jelou.scheduleAt()` recibe una fecha en vez de una duración:

```typescript theme={null}
await ctx.jelou.scheduleAt({
  at: new Date("2026-12-24T18:00:00Z"), // o el string ISO 8601
  path: "/felicitacion-navidad",
  payload: { userId: input.userId },
});
```

Lanza un error si la fecha ya pasó o no se puede interpretar. El resto de campos funciona igual que en `schedule()`.

### Evitar duplicados

Si el servicio que llama a tu función reintenta, `idempotencyKey` garantiza una sola reserva:

```typescript theme={null}
const booking = await ctx.jelou.schedule({
  in: "24h",
  path: "/enviar-seguimiento",
  payload: { userId: input.userId, carritoId: input.carritoId },
  idempotencyKey: `carrito-abandonado:${input.eventoId}`,
});

if (booking.idempotentReplay) {
  ctx.log("Ya estaba agendado, no se duplicó", { id: booking.id });
}
```

Reusar la misma `idempotencyKey` con un `payload` distinto devuelve un error `409`.

## Agendar desde una petición HTTP

Cualquier cliente puede diferir una llamada agregando una cabecera al POST normal. Sin cabeceras de agenda, la función se ejecuta de inmediato como siempre.

```bash theme={null}
curl -X POST https://recordatorios.fn.jelou.ai/enviar-recordatorio \
  -H "Authorization: Bearer <tu-api-key>" \
  -H "X-Jelou-Schedule-In: 5m" \
  -H "X-Jelou-Key: recordatorio:user-42" \
  -H "Content-Type: application/json" \
  -d '{"userId":"42","mensaje":"Tu cita es mañana"}'
```

| Cabecera                 | Efecto                                           |
| ------------------------ | ------------------------------------------------ |
| `X-Jelou-Schedule-In`    | Duración (`"5m"`, `"1h30m"`, `"24h"` o segundos) |
| `X-Jelou-Schedule-At`    | Momento absoluto en ISO 8601                     |
| `X-Jelou-Key`            | Etiqueta para buscar o cancelar después          |
| `X-Jelou-Subject`        | Audiencia, normalmente el ID del usuario         |
| `X-Jelou-Pin-Deployment` | `"true"` para fijar el despliegue actual         |
| `Idempotency-Key`        | Evita agendar dos veces al reintentar            |

La respuesta es `202 Accepted` cuando se crea la reserva y `200 OK` cuando un reintento reutiliza una reserva existente. El cuerpo no puede superar **64 KB**.

## Recibir el disparo

Cuando llega el momento, tu función recibe el `payload` original en la ruta que indicaste. Usa `ctx.isScheduledFire` para distinguir el disparo de una petición normal:

```typescript theme={null}
handler: async (input, ctx) => {
  if (ctx.isScheduledFire) {
    ctx.log("Ejecución diferida disparada");
  }
  // ...
}
```

### Verificar antes de actuar

Entre el momento en que agendas y el momento en que se dispara, la realidad pudo cambiar: el cliente ya pagó, la plantilla se pausó, el usuario se dio de baja. `ctx.guard` encadena esas verificaciones y evita el envío si alguna falla:

```typescript theme={null}
export default define({
  name: "enviar-seguimiento",
  description: "Envía el seguimiento de carrito abandonado si sigue aplicando",
  input: z.object({
    userId: z.string(),
    carritoId: z.string(),
    botId: z.string(),
  }),
  handler: async (input, ctx) => {
    // El botId viajó en el payload que agendaste antes
    const registro = ctx.templateRegistry.for(input.botId);

    return ctx.guard
      .when("sinPagar", async () => !(await consultarCarrito(input.carritoId)).pagado)
      .when("plantillaAprobada", () => registro.has("carrito_abandonado_v3"))
      .run(async () => {
        await ctx.jelou.sendTemplate({
          template: "carrito_abandonado_v3",
          to: input.userId,
          params: ["María", `https://tienda.com/recuperar?c=${input.carritoId}`],
        });
        return { enviado: true };
      });
  },
});
```

Si todas las verificaciones pasan, se ejecuta `.run()` y su resultado es la respuesta. Si alguna falla, la cadena se corta y devuelve `{ skipped: "<nombre>" }` sin ejecutar el envío.

<Note>
  La cadena termina con `.run(handler)`, no con `.then()`. `ctx.guard` es una cadena nueva en cada petición.
</Note>

<Warning>
  Si el bot no viaja en el `payload`, en un disparo diferido `ctx.bot` no está resuelto y `ctx.templateRegistry` queda sin canal asociado. Incluye el `botId` en el `payload` al agendar y enlázalo con `ctx.templateRegistry.for(botId)`. Ver [plantillas de WhatsApp](/guides/functions/mensajeria#validar-plantillas-antes-de-enviar).
</Warning>

## Consultar lo agendado

`ctx.jelou.findDeferred()` lista las ejecuciones pendientes de tu función:

```typescript theme={null}
const { data, total } = await ctx.jelou.findDeferred({
  subject: `user_${input.userId}`,
  status: "scheduled",
});

for (const fila of data) {
  ctx.log("pendiente", fila.id, fila.scheduledAt, fila.key);
}
```

| Campo              | Descripción                                                                           |
| ------------------ | ------------------------------------------------------------------------------------- |
| `key`              | Filtra por etiqueta exacta                                                            |
| `subject`          | Filtra por audiencia                                                                  |
| `status`           | `"active"` (default), `"scheduled"`, `"firing"`, `"fired"`, `"cancelled"`, `"failed"` |
| `page` / `perPage` | Paginación. Default `20`, máximo `100`                                                |

## Cancelar

`ctx.jelou.cancelDefer()` cancela en bloque por audiencia o etiqueta. Requiere exactamente uno de `subject`, `key` o `keyPrefix`:

```typescript theme={null}
// Baja del usuario: cancela todos sus recordatorios pendientes
const { cancelled, raced } = await ctx.jelou.cancelDefer({
  subject: `user_${input.userId}`,
});

ctx.log("baja procesada", { cancelados: cancelled.length, en_curso: raced.length });
```

`raced` contiene las ejecuciones que ya se estaban disparando cuando llegó la cancelación — para esas, la verificación con `ctx.guard` en el handler es la última defensa. Escribe tus handlers para que sean idempotentes.

Antes de una cancelación amplia, previsualiza el alcance con `dryRun`:

```typescript theme={null}
const previa = await ctx.jelou.cancelDefer({
  keyPrefix: "promo-verano:",
  dryRun: true,
});

if (previa.wouldCancelCount < 5000) {
  await ctx.jelou.cancelDefer({ keyPrefix: "promo-verano:" });
}
```

<Note>
  No existe "reprogramar": una reserva es inmutable. Para cambiar la hora, cancela y agenda de nuevo.
</Note>

## Prueba local

`schedule`, `scheduleAt`, `findDeferred` y `cancelDefer` funcionan solo en una función **desplegada**. En local lanzan un error porque no hay credenciales de plataforma.

Para probar el flujo con `jelou functions dev`, activa la simulación:

```bash theme={null}
JELOU_FN_DEFER_DEV=1 jelou functions dev
```

En ese modo se validan los argumentos, se registra la reserva en los logs y se devuelve un resultado sintético — tu handler sigue ejecutándose, pero no se agenda nada real.

Para tests unitarios usa [`createMockContext`](/guides/functions/testing), cuyo `ctx.jelou` registra las llamadas sin red ni configuración.

## Inspeccionar desde el CLI

```bash theme={null}
# Reservas activas de la función del proyecto actual
jelou functions defer list

# Las que fallaron
jelou functions defer list mi-funcion --status failed

# Las de un usuario
jelou functions defer list mi-funcion --subject user_42

# Detalle de una reserva (incluye el último error)
jelou functions defer get dinv_01ksqbvyqpe0prt91exc97mh4n
```

Ver la [referencia del CLI](/guides/functions/cli#diferidas).

## Límites

| Límite                            | Valor                |
| --------------------------------- | -------------------- |
| Reservas activas por empresa      | 1,000                |
| Anticipación máxima               | 30 días              |
| Tamaño del `payload`              | 64 KB                |
| Cancelación en bloque por llamada | 10,000 coincidencias |

Las reservas ya disparadas, canceladas o fallidas no cuentan contra el límite de activas.

## Problemas comunes

<Tabs>
  <Tab title="No se disparó">
    Consulta el estado y el último error de la reserva:

    ```bash theme={null}
    jelou functions defer get dinv_01ksqbvy... --payload
    ```

    Si el estado es `failed`, el campo de error indica por qué falló la entrega. Si es `cancelled`, algo la canceló antes — revisa tus llamadas a `cancelDefer`.
  </Tab>

  <Tab title="Se envió aunque cancelé">
    Una cancelación que llega cuando la ejecución ya empezó no la detiene. Aparece en `raced` y tu handler se ejecuta.

    Por eso la verificación va en el handler, no solo en la cancelación:

    ```typescript theme={null}
    return ctx.guard
      .when("sigueActivo", async () => await usuarioActivo(input.userId))
      .run(async () => { /* envío */ });
    ```
  </Tab>

  <Tab title="Error en local">
    ```
    scheduleAt unavailable: platform credentials missing
    ```

    Estás llamando a la familia `schedule` en local. Arranca el servidor con `JELOU_FN_DEFER_DEV=1 jelou functions dev` para simular las reservas.
  </Tab>

  <Tab title="Se agendó dos veces">
    El servicio que llama a tu función reintentó. Agrega `idempotencyKey` (o la cabecera `Idempotency-Key`) con un valor derivado del evento:

    ```typescript theme={null}
    idempotencyKey: `carrito-abandonado:${input.eventoId}`
    ```
  </Tab>
</Tabs>

<CardGroup cols={2}>
  <Card title="Cron" icon="clock" href="/guides/functions/cron">
    Tareas recurrentes en horario fijo.
  </Card>

  <Card title="Mensajería" icon="message" href="/guides/functions/mensajeria">
    Enviar WhatsApp y validar plantillas.
  </Card>

  <Card title="Webhooks" icon="shield-check" href="/guides/functions/webhooks">
    Verificar firmas de servicios externos.
  </Card>

  <Card title="CLI" icon="terminal" href="/guides/functions/cli">
    Comandos `defer list` y `defer get`.
  </Card>
</CardGroup>
