---
title: "Scheduled Tasks"
description: "Recurring AI tasks powered by Mastra workflows on Inngest"
source: /docs/scheduled-tasks
---


Overview [#overview]

Scheduled Tasks let any authenticated user schedule a natural-language prompt to run on a cron. Each tick invokes the assistant agent as that user, records the run, and emits the next scheduled event. The runtime is [Inngest](https://www.inngest.com) via `@mastra/inngest`; there is no Cloudflare cron or DB polling.

Tasks are authored as Mastra workflows (`runScheduledTaskWorkflow`) so future multi-step flows — "fetch → compose → approve → act" — can be added without changing the scheduling machinery.

How It Works [#how-it-works]

1. User creates a task via `POST /scheduled-tasks`. The API validates the cron expression, computes `nextRunAt`, and emits a future-dated Inngest event: `scheduled-task/tick` with `ts: nextRunAt` and `data: { taskId }`.
2. Inngest durably holds the event until fire time.
3. The Mastra dispatcher (`runScheduledTaskDispatcher`) receives the event, loads the task, mints a short-TTL scheduler JWT with the creator's snapshot role, invokes the workflow, and records the run.
4. The dispatcher computes the next fire time, emits a new future-dated `scheduled-task/tick`, and updates `nextRunAt`.
5. On disable or delete the chain self-terminates — the next tick sees the task missing or `enabled = false` and no-ops.

This "self-perpetuating scheduled event" pattern lets Inngest handle arbitrary per-user cron expressions without registering dynamic cron functions.

Plan Gating [#plan-gating]

Scheduled tasks are gated by the plan features in `packages/shared/src/plans.ts`:

| Plan       | `scheduledTasks` | `scheduledTasksMaxActive` |
| ---------- | ---------------- | ------------------------- |
| Free       | `false`          | `0`                       |
| Pro        | `true`           | `5`                       |
| Enterprise | `true`           | `50`                      |

Disabled tasks do not count against the quota. The API enforces on create and on enable (`POST /scheduled-tasks`, `PATCH /:id`). The web UI shows an upsell card when the feature is off and disables the "Add" button when the org is at quota.

Role Behavior [#role-behavior]

Execution runs as the task creator, not as a service principal. The dispatcher mints an HS256 JWT with:

* `sub`: the creator's `authUserId`
* `organizationId`: the org the task was created in
* `role`: a **snapshot** of the creator's org role at task creation time (`scheduled_tasks.auth_user_role`)
* `iss`: `cf-scheduler`, TTL 10 min

Tool calls from the scheduled run authenticate with these claims, so role gating at tool level is unchanged. A non-admin who schedules an admin-only tool call gets a permission error on each tick; the chain continues.

The snapshot is frozen at creation time. If you need live role lookup, migrate to the Better Auth admin plugin (deferred — out of scope for v1).

Configuration [#configuration]

Environment Variables [#environment-variables]

| Variable               | Where                      | Purpose                                                                                                                                                            |
| ---------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `INNGEST_EVENT_KEY`    | `apps/api`                 | API uses this to `inngest.send()` tick events                                                                                                                      |
| `INNGEST_SIGNING_KEY`  | `apps/mastra`              | Mastra `serve()` handler verifies inbound Inngest requests                                                                                                         |
| `SCHEDULER_JWT_SECRET` | `apps/api` + `apps/mastra` | Shared HS256 secret for scheduler-minted JWTs. API uses it for internal `/admin-internal/scheduled-tasks/*` routes; Mastra uses it in the auth middleware fallback |
| `MASTRA_URL`           | `apps/api`                 | Base URL the API and external callers use — not required for Inngest itself                                                                                        |
| `API_SERVICE_URL`      | `apps/mastra`              | Base URL the dispatcher uses to call the API's internal routes                                                                                                     |

`scripts/conductor/deploy.sh` pushes all three secrets to the right Workers on deploy. `SCHEDULER_JWT_SECRET` is auto-generated on first deploy if not present in `scripts/env/.env.dev-secrets`.

Inngest App Setup [#inngest-app-setup]

One-time setup at [app.inngest.com](https://app.inngest.com):

1. Create a new Inngest app.
2. Grab the Event Key (**Manage → Event Keys**) and the Signing Key (**Manage → Signing Key**). Put them in `scripts/env/.env.dev-secrets` as `INNGEST_EVENT_KEY` and `INNGEST_SIGNING_KEY`, and in the GitHub repo secrets for CI environments.
3. Deploy. `scripts/conductor/deploy.sh` and `.github/workflows/deploy-environment.yml` both PUT to `/inngest` after the Mastra worker deploys, so Inngest auto-discovers the dispatcher and workflow functions. No manual sync in the dashboard.

Because the sync runs on every deploy, each conductor workspace and each `development`/`preview` push keeps its function list up to date without extra steps.

Local Development [#local-development]

Run the Inngest dev server pointed at the Mastra worker — no Inngest account needed:

```bash
npx inngest-cli@latest dev -u http://localhost:4111/inngest
```

The dashboard at [localhost:8288](http://localhost:8288) shows registered functions, pending events, and run history. Leave `INNGEST_EVENT_KEY` and `INNGEST_SIGNING_KEY` blank in `.dev.vars` / `.env` — the dev server accepts unsigned requests.

Still set `SCHEDULER_JWT_SECRET` (identical value in `apps/api/.dev.vars` and `apps/mastra/.env`) so service-to-service calls and tool JWTs verify.

Database [#database]

Two tables in the API business database:

* `scheduled_tasks` — one row per task. Holds the cron, prompt, enabled flag, `next_run_at`, and the `auth_user_role` snapshot.
* `scheduled_task_runs` — one row per run. Status, response, error, Mastra thread id.

Migrations: `migrations/0013_add_scheduled_tasks.sql` and `migrations/0014_scheduled_tasks_auth_user_role.sql`.

API Surface [#api-surface]

User-Facing (JWT-authenticated) [#user-facing-jwt-authenticated]

| Route                               | Purpose                                            |
| ----------------------------------- | -------------------------------------------------- |
| `GET /scheduled-tasks`              | List. Members see own; admin+ see org-wide         |
| `POST /scheduled-tasks`             | Create. Plan + quota gated                         |
| `GET /scheduled-tasks/:id`          | Get one                                            |
| `PATCH /scheduled-tasks/:id`        | Update. Re-emits tick on schedule change or enable |
| `DELETE /scheduled-tasks/:id`       | Delete. Chain self-terminates on next tick         |
| `POST /scheduled-tasks/:id/run-now` | Emit an immediate tick (does not change cron)      |
| `GET /scheduled-tasks/:id/runs`     | Paginated run history                              |

Internal (scheduler JWT only) [#internal-scheduler-jwt-only]

The dispatcher calls these under `/admin-internal/scheduled-tasks/*` using a minimal `sub: "scheduler"` JWT. They are not exposed to end users.

Troubleshooting [#troubleshooting]

**Tasks don't fire.** Check the Inngest dashboard for pending `scheduled-task/tick` events. If none, the initial `inngest.send()` from the API didn't go through — verify `INNGEST_EVENT_KEY` on the API Worker.

**Function registration fails.** Inngest cannot reach the Mastra worker. Verify the registered URL is correct and `/inngest` returns 200 on GET.

**Tool calls from scheduled runs return 401/403.** The scheduler JWT's `role` claim is the snapshot taken at task creation. If the user's role in the org has changed since, create a new task — the snapshot doesn't auto-sync.

**Chain dies unexpectedly.** Check the Mastra worker logs for the tick event — if the task row was deleted or flipped to `enabled = false`, the dispatcher logs `"scheduled-task/tick: skipping"` and intentionally doesn't re-emit.
