Scheduled Tasks
Recurring AI tasks powered by Mastra workflows on Inngest
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 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
- User creates a task via
POST /scheduled-tasks. The API validates the cron expression, computesnextRunAt, and emits a future-dated Inngest event:scheduled-task/tickwithts: nextRunAtanddata: { taskId }. - Inngest durably holds the event until fire time.
- 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. - The dispatcher computes the next fire time, emits a new future-dated
scheduled-task/tick, and updatesnextRunAt. - On disable or delete the chain self-terminates — the next tick sees the task missing or
enabled = falseand no-ops.
This "self-perpetuating scheduled event" pattern lets Inngest handle arbitrary per-user cron expressions without registering dynamic cron functions.
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
Execution runs as the task creator, not as a service principal. The dispatcher mints an HS256 JWT with:
sub: the creator'sauthUserIdorganizationId: the org the task was created inrole: 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
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
One-time setup at app.inngest.com:
- Create a new Inngest app.
- Grab the Event Key (Manage → Event Keys) and the Signing Key (Manage → Signing Key). Put them in
scripts/env/.env.dev-secretsasINNGEST_EVENT_KEYandINNGEST_SIGNING_KEY, and in the GitHub repo secrets for CI environments. - Deploy.
scripts/conductor/deploy.shand.github/workflows/deploy-environment.ymlboth PUT to/inngestafter 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
Run the Inngest dev server pointed at the Mastra worker — no Inngest account needed:
npx inngest-cli@latest dev -u http://localhost:4111/inngestThe dashboard at 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
Two tables in the API business database:
scheduled_tasks— one row per task. Holds the cron, prompt, enabled flag,next_run_at, and theauth_user_rolesnapshot.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
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)
The dispatcher calls these under /admin-internal/scheduled-tasks/* using a minimal sub: "scheduler" JWT. They are not exposed to end users.
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.