Schedules

Schedules represent time-triggered application work. Use a schedule when the code says "run this at this time": send daily digests, sync billing state, clean expired records, refresh search indexes, or generate periodic reports.

Beignet schedules are typed definitions. They describe the cron expression, optional timezone, payload schema, and handler. The runtime that triggers them can be a cron route, Inngest, Vercel Cron, a worker process, or an app-owned adapter.

bun add @beignet/core

Define a schedule

Create the app-bound defineSchedule builder once in lib/schedules.ts with createSchedules<AppContext>() (see app-bound builders), then define schedules in feature files:

import { z } from "zod";
import { defineSchedule } from "@/lib/schedules";

export const SendDailyDigestSchedule = defineSchedule(
  "digests.send-daily",
  {
    cron: "0 9 * * *",
    timezone: "America/Chicago",
    payload: z.object({
      date: z.string(),
    }),
    createPayload({ run }) {
      const date = run.scheduledAt ?? run.triggeredAt;

      return {
        date: date.toISOString().slice(0, 10),
      };
    },
    async handle({ payload, ctx }) {
      await ctx.ports.jobs.dispatch(SendDigestEmailJob, {
        date: payload.date,
      });
    },
  },
);

The payload schema is validated before the handler runs. createPayload(...) lets a provider or cron route trigger the schedule with timing metadata while the schedule owns the app-specific payload.

Register app schedules in server/schedules.ts when they should be available to operational runners. beignet make schedule creates this registry when it does not exist yet and appends new feature schedule registries to it:

import { createServiceActor } from "@beignet/core/ports";
import type { AppContext } from "@/app-context";
import { digestSchedules } from "@/features/digests/schedules";
import { server } from "@/server";

export const schedules = [...digestSchedules] as const;

export async function createScheduleContext(): Promise<AppContext> {
  return server.createServiceContext({
    actor: createServiceActor("beignet-schedule"),
  });
}

export async function stopScheduleContext(): Promise<void> {
  await server.stop();
}

server.createServiceContext(...) builds a service context for background work. beignet doctor reports feature schedules that never made it into this registry, and beignet doctor --fix registers them.

Run inline

Use the inline runner for tests, local scripts, and app-owned trigger adapters:

import { createInlineScheduleRunner } from "@beignet/core/schedules";

const runner = createInlineScheduleRunner<AppContext>({
  ctx: createBackgroundContext,
  onError({ error, schedule }) {
    logger.error("Schedule failed", {
      error,
      scheduleName: schedule.name,
    });
  },
});

await runner.run(SendDailyDigestSchedule, {
  scheduledAt: new Date("2026-01-01T09:00:00.000Z"),
  attempt: 1,
  source: "vercel-cron",
});

Pass payload explicitly when a test or script needs full control instead of deriving it through createPayload(...).

Run registered schedules from the Beignet CLI for local, CI, or worker-hosted entrypoints:

beignet schedule run digests.send-daily --scheduled-at 2026-01-01T09:00:00.000Z

The CLI loads server/schedules.ts, creates the app context, runs one schedule, records schedule events into the resolved instrumentation port when one exists, and then calls stopScheduleContext(...). Omit --payload to use createPayload(...); pass --payload '{"date":"2026-01-01"}' when the caller supplies the schedule payload.

Expose a cron route

In Next.js apps, prefer createScheduleRoute(...) from @beignet/next so the public trigger route stays small. The helper authenticates the schedule provider with a timing-safe bearer comparison, creates an app context, runs the schedule inline, and records schedule events through the instrumentation port resolved from ctx.ports:

// app/api/cron/digests/daily-digest/route.ts
import { createScheduleRoute } from "@beignet/next";
import { env } from "@/lib/env";
import { server } from "@/server";
import { schedules } from "@/server/schedules";

export const runtime = "nodejs";

export const { GET, POST } = createScheduleRoute({
  server,
  schedules,
  schedule: "digests.send-daily",
  secret: env.CRON_SECRET,
  source: "vercel-cron",
});

The schedule name is resolved when the route module loads, so a typo or an unregistered schedule fails at build or boot time instead of at the first cron invocation. Export both GET and POST when your scheduler may call either method. The schedule remains the reusable unit — Vercel Cron, Inngest, a worker, or a local script can all trigger the same definition — and schedule failures return a 500 response so providers can retry failed invocations.

Cron routes fail closed when CRON_SECRET is missing. Set it in your deployment environment and send Authorization: Bearer <secret> from your cron provider. beignet make schedule --route scaffolds this route and adds a generated CRON_SECRET to lib/env.ts and .env.example when the app does not define one yet.

See Deployment for when to use cron routes, scheduled functions, worker-hosted tasks, or beignet schedule run.

Schedules and jobs

Schedules decide when work should begin. Jobs own durable work. See Workflow primitives when deciding whether the scheduled handler should call a command use case, dispatch a job, send a notification, or write an outbox message.

For production workflows, prefer schedule handlers that dispatch jobs. That keeps scheduled triggers small, makes retries a job-provider concern, and lets the same job run from HTTP, events, scripts, or manual admin actions.

Failure semantics

Schedules do not define retry policies. They are trigger definitions. Cron providers, worker hosts, and queue systems decide whether to retry a failed schedule invocation, with provider-owned backoff. Beignet preserves that behavior by rethrowing handler failures after onError runs. Dead-lettering is not a schedule concept in core: for critical work, keep the schedule handler small and dispatch a job or outbox message so retry policy, backoff, and dead-letter behavior move into Beignet's durable primitives, described in the retry vocabulary.

Observability

Pass an instrumentation sink as instrumentation and the inline runner records first-class schedule events for each run: started before the handler, completed after it, and failed with the error when payload creation, validation, or the handler throws:

import { resolveProviderInstrumentationPort } from "@beignet/core/providers";

const runner = createInlineScheduleRunner<AppContext>({
  ctx,
  instrumentation: resolveProviderInstrumentationPort(ctx.ports),
  instrumentationContext: {
    requestId: ctx.requestId,
    traceId: ctx.traceId,
  },
});

instrumentationContext attaches request correlation fields so the devtools request view can expand into the schedule runs triggered by that invocation. createScheduleRoute(...) and beignet schedule run wire this up automatically from the instrumentation port resolved from ctx.ports, and providers can emit the same schedule devtools events through provider instrumentation.

For custom logging or metrics, the runner also exposes onStart, onSuccess, and onError lifecycle hooks. Hook failures are isolated from schedule execution and reported to onHookError when provided — they never prevent the handler from running or turn a successful run into a failure. Schedule handler failures still reject runner.run(...) after onError runs, which keeps onError useful for logging while preserving normal retry behavior for cron routes and workers.

Testing

Schedule tests can run handlers directly through the inline runner:

const runner = createInlineScheduleRunner<AppContext>({
  ctx,
  now: () => new Date("2026-01-01T09:00:00.000Z"),
});

await runner.run(SendDailyDigestSchedule, {
  scheduledAt: "2026-01-01T09:00:00.000Z",
});

Use in-memory or fake ports in ctx and assert on the resulting repository, job, mail, log, or devtools effects.