Docs Running Schedules

Schedules

A Schedule runs an App automatically on a cron expression. This is what turns "I built a useful App" into "the report shows up in my Slack every Monday morning without me thinking about it." Schedules are the difference between a workflow that exists and one that compounds.

The mental model. A Schedule is a Cron expression × a set of inputs × a delivery destination. You're saying: "every Monday at 9 AM, run this App with these defaults, and send the result here." The platform handles the rest — provision a sandbox, run the stages, save the artifacts, deliver them, log everything.

Anatomy of a Schedule

The platform stores Schedules in app_scheduled_tasks. The shape is the same whether the schedule runs an App or sends a chat prompt — the difference is action_type.

{
  "id": "task_...",
  "name": string,
  "enabled": boolean,
  "schedule_type": "interval" | "daily" | "weekly" | "monthly",
  "schedule_config": ScheduleConfig, // preset-shaped, see below
  "cron_expr": string, // computed from config; read-only
  "timezone": "America/Los_Angeles"// IANA name
  "action_type": "send_message" | "run_app",
  "action_config": ActionConfig, // shape depends on action_type
  "session_id": string | null, // auto-created on first send_message tick
  "run_count": number,
  "last_run_at": timestamp | null,
  "next_run_at": timestamp, // scheduler picks tasks where next_run_at <= NOW()
  "last_status": "ok" | "error" | "queued" | null,
  "last_error": string | null
}

ScheduleConfig — the four shapes

You don't write cron. You pick one of four schedule_types; each takes a small config object. The server derives cron_expr (a human-readable label) and computes next_run_at using IANA timezone math.

// schedule_type = "interval"
{ "every": 15, "unit": "minutes" }  // unit: "minutes" | "hours"

// schedule_type = "daily"
{ "time": "09:00" }  // HH:MM in the task's timezone

// schedule_type = "weekly"
{ "time": "09:00", "days": [1,3,5] }  // 0=Sun .. 6=Sat — Mon/Wed/Fri here

// schedule_type = "monthly"
{ "time": "09:00", "day": 1 }  // 1–28 (28 to stay safe across all months)

ActionConfig — what the tick does

Two kinds of action. The validator picks them apart at POST /api/scheduled-tasks:

// action_type = "send_message" — enqueue a prompt to a session
{ "prompt": "Summarize my GitHub activity since yesterday." }

// action_type = "run_app" — fire an AppExecution
{
  "app_id": "app_...",
  "app_input": { company_name: "Stripe", ... }
}

For send_message, the first tick creates a dedicated chat session (session_type = 'scheduled') and stores the session_id back on the task. Every subsequent tick enqueues a new message into the same session — so the agent has continuity across runs, which is what makes "every morning, check on X" actually feel like a recurring conversation.

For run_app, before each tick the scheduler verifies that all required Connects are still authorized. A run with missing Connects records last_status = 'error' with a "Missing required connections: …" message instead of starting the execution.

Creating a Schedule

On any App page, click Schedules → New schedule. The wizard asks for three things:

1
When to run. Pick a preset (Hourly, Daily, Weekly, Monthly) or write a cron expression. Times use your workspace's time zone — set it before relying on schedules.
2
What inputs to use. Fill the App's form with values that should be used on every run. Use dynamic placeholders ({{today}}, {{last_week_start}}) for time-relative inputs.
3
Where to deliver the result. One or more destinations: email, Slack channel, Drive folder, Notion page, webhook.

Save, and the schedule is live. The Next run field on the App page shows the upcoming tick. Open the Schedules tab any time to pause, edit, run-now, or delete.

Common patterns expressed as ScheduleConfig

The same recurring runs you'd describe in cron, written in Aitroop's preset shape. The cron_expr column is what the server computes for display — it's never user-editable.

Patternschedule_typeschedule_configComputed cron_expr
Every 15 minutesinterval{ every: 15, unit: "minutes" }every 15 min
Every hourinterval{ every: 1, unit: "hours" }every 1 hour
Daily at 9 AMdaily{ time: "09:00" }0 9 * * *
Mon/Wed/Fri at 8:30 AMweekly{ time: "08:30", days: [1,3,5] }30 8 * * 1,3,5
Every Monday at 9 AMweekly{ time: "09:00", days: [1] }0 9 * * 1
1st of the month at 9 AMmonthly{ time: "09:00", day: 1 }0 9 1 * *
Minimum cadence: 5 minutes. The server rejects any interval below 300 seconds with 422 invalid schedule. The scheduler also self-defends — if it picks up a task whose config has drifted below 5 min, it pushes next_run_at forward and skips the tick.

What "cron" means here (and what it doesn't)

Aitroop's cron_expr field is a derived label, not an input — there is no place to paste a 5-field expression like */30 9-17 * * MON-FRI. The four schedule_types cover the cases the platform supports, and the executor doesn't parse a cron grammar at runtime. If you need expressiveness beyond the presets, the canonical pattern is:

  • Schedule the task daily at the earliest time it could fire.
  • Add a guard at the top of the App (a script stage) that checks "is today the right day to actually do the work?" and exits cheaply if not.

Timezones

Each task carries its own IANA timezone string (e.g. America/Los_Angeles). For daily / weekly / monthly, the time field is interpreted in that timezone — so a Daily 09:00 schedule in America/Los_Angeles fires at 9 AM Pacific year-round, automatically tracking DST through Intl-based offset math (see computeNextRun in scheduledTasks.ts). Default timezone is UTC.

Scheduler internals

A worker ticks the scheduler roughly every 60 seconds. Each tick picks up every task with enabled = true AND next_run_at <= NOW(), in batches of 50. For each due task it calls executeTask, then writes back last_run_at, last_status, and the freshly computed next_run_at. Manually triggering via POST /api/scheduled-tasks/:id/run goes through the same executeTask path so manual and scheduled runs are indistinguishable downstream.

Dynamic inputs — defaults that shift on each run

Most scheduled Apps need a moving anchor — "yesterday's tickets", "last month's expenses". The right place to make this happen is on the App's input_schema, not on the schedule. Set dynamic_default on the relevant input field and leave that field out of action_config.app_input; the executor fills it at run time.

dynamic_defaultResolves to (sample on 2026-05-31)
today2026-05-31
yesterday2026-05-30
last7days2026-05-24 → 2026-05-31 (for daterange)
last30days2026-05-01 → 2026-05-31
thisMonth2026-05-01 → 2026-05-31
lastMonth2026-04-01 → 2026-04-30

A weekly engineering report with a daterange input set to dynamic_default: "last7days" always covers the right window — you configure it once and the schedule never needs to touch the date again.

Why not goal-side templating? Earlier docs implied scheduled inputs could use placeholders like {{last_week_start}}. They can't — the goal expander is a literal-key substitutor (see Core concepts). All time-relativity lives on the input schema.

What happens to the result

A scheduled run produces the same artifact set as a manual run, and lands in the same place: app_artifact rows under the AppExecution (for run_app), or the chat session's message history (for send_message). There's no separate "delivery" subsystem — to get the result somewhere external, build it into the App:

  • To email a report — add a stage that uses the user's authorized email Connect to send.
  • To post to Slack — give the App the Slack Connect and have the final stage post.
  • To save to Drive / Notion — same pattern, via the corresponding Connect.
  • To push to your own webhook — add a script stage that POSTs.

Every run is also recorded in app_task_runs with status (queuedok / error), and the App's Executions tab shows the artifacts as usual.

Running on demand from outside the platform

Schedules trigger by cron. To trigger manually from a script or webhook handler:

POST /api/scheduled-tasks/:id/run
Authorization: Bearer $AT_USER_TOKEN

The schedule runs once, immediately, with its configured inputs and delivery targets. Useful for pushing a refresh ahead of the next scheduled tick.

App versions and Schedules

A Schedule always uses the App's current version. When you edit the App, the next tick picks up the change — there is no pinning UI today. The execution record stores app_version at the time of run, so the history remains unambiguous, but you can't freeze a Schedule to an older version. If you need version stability, the practical workaround is to clone the App, freeze the clone, and point the Schedule at the clone.

When a scheduled run fails

Every tick writes a row to app_task_runs with a status and triggered (scheduler | manual). The owning task row records the most recent outcome: last_status (ok / error / queued), last_error, last_run_at. Failures don't auto-retry — they wait for the next tick.

Common failure modes

SymptomLikely causeFix
last_error: "Missing required connections: …"A Connect that the App requires was revoked or expired before the tick.Re-authorize the Connect in Settings → Connects. The next tick proceeds.
Stage timeoutGoal got more expensive over time (more data, longer searches).Bump timeout_ms on the affected stage.
Empty resultWindow from dynamic_default resolved to a span with no data.Widen the window, or have the stage gracefully report "no activity".
Skipped below minimumAn interval drifted below 5 minutes (typically via direct DB edits).The scheduler pushes next_run_at forward by 5 min and skips. Fix the interval via PUT.

Pausing and editing

Toggle a Schedule off without deleting it — useful during incidents or while editing the App. Edit any field — cron, inputs, delivery — and the next run uses the new values.

Cost-aware scheduling. A 15-minute schedule is 4× the cost of an hourly one, and 96× the cost of a daily one. Before cranking up the frequency, ask whether anything actually changes that often. Most "real-time" alerting needs are well-served by checking once an hour.

FAQ & troubleshooting

My schedule didn't fire at 9 AM as expected.

Most common causes:

  • Wrong timezone. Default is UTC. Either pass an IANA timezone when you create the task, or update it via PUT /api/scheduled-tasks/:id.
  • DST transition. The computeNextRun implementation walks the IANA database so the wall-clock time stays fixed across DST changes. Inspect next_run_at in app_scheduled_tasks to see what the scheduler thinks the next tick should be.
  • Task disabled. Someone set enabled = false. Inspect the row directly or via GET /api/scheduled-tasks.
  • Tick window. The scheduler runs roughly every 60 s — a tick can lag the configured time by up to a minute on a busy server.

How do I run "once per quarter on the second business Tuesday"?

The four presets don't cover this, and there's no raw-cron escape hatch. The supported pattern is "schedule daily, guard in the App":

  • Set schedule_type: "daily" at a chosen wall-clock time.
  • Make the App's first stage a script stage that checks the date and exits cheaply when today isn't the right day.

Can I trigger a Schedule from a Slack slash command?

Yes — point a Slack slash command at a webhook URL that POSTs to /api/scheduled-tasks/:id/run. The Schedule runs once with its configured inputs. Verify the Slack signing secret on your handler side.

My App now needs an extra input. Will old Schedules break?

They keep running. action_config.app_input is passed through verbatim, so missing keys fall back to whatever the App's input_schema specifies — the field's default, or dynamic_default at run time. If the new input is required with no default, the next tick fails at the validator with a Missing required input error. Update the task via PUT /api/scheduled-tasks/:id with the new key in action_config.app_input.

Where do I see what's queued or running?

List the tasks themselves, then drill into per-task run history:

# All schedules for the current user
curl https://app.aitroop.net/api/scheduled-tasks \
  -H "Authorization: Bearer $AT_USER_TOKEN"

# Last 50 runs of one schedule (status / triggered / started_at)
curl https://app.aitroop.net/api/scheduled-tasks/$ID/runs \
  -H "Authorization: Bearer $AT_USER_TOKEN"

Can I version-control my Schedules?

Yes — read with GET /api/scheduled-tasks (or filter to one task and persist the JSON to a repo). Recreate via POST or amend an existing task via PUT /api/scheduled-tasks/:id. There is no per-task GET endpoint — list everything and pick the row you want.