Schedules API
Schedules automate recurring task execution. Each schedule defines a prompt, an interval (cron, shorthand, or natural language), and an optional agent assignment. Stagent supports two schedule types: standard scheduled tasks that fire on a cron cadence, and heartbeat monitors that evaluate a checklist and only act when conditions warrant. The Schedules API supports CRUD operations, interval parsing preview, and heartbeat history retrieval.
Quick Start
Preview how an interval parses, create a recurring schedule, and check its firing history:
// 1. Parse the interval to preview how it resolves before committing
const parsed: ParseResult = await fetch('/api/schedules/parse', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ expression: 'every weekday at 9am' }),
}).then(r => r.json());
console.log(parsed.cronExpression); // "0 9 * * 1-5"
console.log(parsed.description); // "At 09:00 AM, Monday through Friday"
console.log(parsed.nextFireTimes); // ["2026-04-06T09:00:00Z", ...]
console.log(parsed.confidence); // 0.95
// 2. Create the schedule — it starts firing immediately
const schedule: Schedule = await fetch('/api/schedules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Daily standup summary',
prompt: 'Review all open tasks, summarize blockers, and draft a standup message',
interval: 'every weekday at 9am',
projectId: 'proj-8f3a-4b2c',
assignedAgent: 'claude-code',
agentProfile: 'project-manager',
}),
}).then(r => r.json());
// → { id: "sched-e7b2-1f4a", status: "active", nextFireAt: "2026-04-06T09:00:00Z", ... }
// 3. Check the schedule detail to see past firings
const detail: ScheduleDetail = await fetch(`/api/schedules/${schedule.id}`)
.then(r => r.json());
console.log(`Fired ${detail.firingCount} times`);
detail.firingHistory.forEach(task => {
console.log(` [${task.status}] ${task.title} — ${task.createdAt}`);
}); Base URL
/api/schedules
Endpoints
List Schedules
/api/schedules Retrieve all schedules ordered by newest first.
Response 200 — Array of schedule objects
Schedule Object
| Field | Type | Req | Description |
|---|---|---|---|
| id | string (UUID) | * | Schedule identifier |
| name | string | * | Schedule display name |
| prompt | string | * | Task prompt executed on each firing |
| cronExpression | string | * | Parsed cron expression |
| projectId | string (UUID) | — | Associated project |
| assignedAgent | string | — | Agent runtime ID |
| agentProfile | string | — | Profile ID for execution |
| type | enum | * | scheduled or heartbeat |
| status | enum | * | active, paused, or exhausted |
| recurs | boolean | * | Whether the schedule repeats |
| maxFirings | number | — | Maximum number of firings before auto-exhaust |
| firingCount | number | * | Total firings so far |
| nextFireAt | ISO 8601 | — | Next scheduled fire time (null when paused) |
| expiresAt | ISO 8601 | — | Expiration timestamp |
| createdAt | ISO 8601 | * | Creation timestamp |
| updatedAt | ISO 8601 | * | Last modification timestamp |
Fetch all schedules to build a scheduling dashboard — shows active, paused, and exhausted schedules:
// List schedules and show when each will next fire
const schedules: Schedule[] = await fetch('/api/schedules').then(r => r.json());
const active = schedules.filter(s => s.status === 'active');
active.forEach(s => {
const next = s.nextFireAt ? new Date(s.nextFireAt).toLocaleString() : 'none';
console.log(`${s.name} — next: ${next} (fired ${s.firingCount}x)`);
}); Example response:
[
{
"id": "sched-e7b2-1f4a",
"name": "Daily standup summary",
"prompt": "Review all open tasks, summarize blockers, and draft a standup message",
"cronExpression": "0 9 * * 1-5",
"projectId": "proj-8f3a-4b2c",
"assignedAgent": "claude-code",
"agentProfile": "project-manager",
"type": "scheduled",
"status": "active",
"recurs": true,
"firingCount": 14,
"nextFireAt": "2026-04-04T09:00:00.000Z",
"createdAt": "2026-03-15T10:00:00.000Z",
"updatedAt": "2026-04-03T09:00:00.000Z"
}
] Create Schedule
/api/schedules Create a new schedule. The interval field accepts natural language (e.g. 'every weekday at 9am'), shorthand ('5m', '2h', '1d'), or raw cron expressions. Heartbeat schedules require a checklist.
Request Body
| Field | Type | Req | Description |
|---|---|---|---|
| name | string | * | Schedule name |
| prompt | string | * | Task prompt (required for scheduled type, optional for heartbeat) |
| interval | string | * | Interval expression: natural language, shorthand, or cron |
| projectId | string (UUID) | — | Project to associate with |
| assignedAgent | enum | — | Agent runtime for execution |
| agentProfile | string | — | Profile ID for agent configuration |
| type | enum | — | scheduled (default) or heartbeat |
| recurs | boolean | — | Whether to repeat (default: true) |
| maxFirings | number | — | Stop after N firings |
| expiresInHours | number | — | Auto-expire after N hours |
| documentIds | string[] | — | Document UUIDs to attach as inputs |
Heartbeat-specific Fields
| Field | Type | Req | Description |
|---|---|---|---|
| heartbeatChecklist | ChecklistItem[] | * | Array of { id, instruction, priority } items (required for heartbeat type) |
| activeHoursStart | number (0–23) | — | Start of active hours window |
| activeHoursEnd | number (0–23) | — | End of active hours window |
| activeTimezone | string | — | IANA timezone for active hours (default: UTC) |
| heartbeatBudgetPerDay | number | — | Daily budget limit for heartbeat actions |
Response 201 Created — The created schedule object
Errors: 400 — Validation failure, invalid interval, or runtime/profile incompatibility
Create a standard recurring schedule that fires every weekday morning:
// Create a schedule with natural language interval
const schedule: Schedule = await fetch('/api/schedules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Daily standup summary',
prompt: 'Review all open tasks, summarize blockers, and draft a standup message',
interval: 'every weekday at 9am',
projectId: 'proj-8f3a-4b2c',
assignedAgent: 'claude-code',
agentProfile: 'project-manager',
}),
}).then(r => r.json());
console.log(schedule.cronExpression); // "0 9 * * 1-5"
console.log(schedule.nextFireAt); // "2026-04-04T09:00:00.000Z" Create a heartbeat monitor with active hours and a daily budget — the agent evaluates a checklist and only acts when conditions warrant:
const heartbeat: Schedule = await fetch('/api/schedules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Production health check',
type: 'heartbeat',
interval: '30m',
assignedAgent: 'claude-code',
agentProfile: 'devops-engineer',
heartbeatChecklist: [
{ id: 'uptime', instruction: 'Check that all API endpoints return 200', priority: 1 },
{ id: 'errors', instruction: 'Check error rate is below 1%', priority: 1 },
{ id: 'latency', instruction: 'Check P95 latency is under 500ms', priority: 2 },
],
activeHoursStart: 8,
activeHoursEnd: 22,
activeTimezone: 'America/New_York',
heartbeatBudgetPerDay: 5.00,
}),
}).then(r => r.json()); Get Schedule
/api/schedules/{id} Retrieve a single schedule with its parsed heartbeat checklist and firing history (child tasks).
Response 200 — Extended schedule object
Additional Fields
| Field | Type | Req | Description |
|---|---|---|---|
| heartbeatChecklist | ChecklistItem[] | — | Parsed checklist array (null for scheduled type) |
| firingHistory | Task[] | * | Child tasks created by this schedule, newest first |
Errors: 404 — Schedule not found
Fetch a schedule with its firing history to see every task it has spawned:
// Get schedule detail with full firing history
const schedule: ScheduleDetail = await fetch('/api/schedules/sched-e7b2-1f4a')
.then(r => r.json());
console.log(`${schedule.name} — ${schedule.firingCount} firings`);
// Show recent firing results
schedule.firingHistory.slice(0, 5).forEach(task => {
console.log(` [${task.status}] ${task.title} — ${new Date(task.createdAt).toLocaleDateString()}`);
}); Example response:
{
"id": "sched-e7b2-1f4a",
"name": "Daily standup summary",
"cronExpression": "0 9 * * 1-5",
"type": "scheduled",
"status": "active",
"firingCount": 14,
"nextFireAt": "2026-04-04T09:00:00.000Z",
"heartbeatChecklist": null,
"firingHistory": [
{
"id": "task-c2a8-5e1d",
"title": "Daily standup summary (run #14)",
"status": "completed",
"createdAt": "2026-04-03T09:00:00.000Z"
},
{
"id": "task-a1b7-3f9c",
"title": "Daily standup summary (run #13)",
"status": "completed",
"createdAt": "2026-04-02T09:00:00.000Z"
}
]
} Update Schedule
/api/schedules/{id} Update schedule fields. Status changes follow strict transitions: only active can be paused, only paused can be resumed. Changing the interval recomputes the next fire time.
Request Body (all fields optional)
| Field | Type | Req | Description |
|---|---|---|---|
| status | enum | — | Set to paused or active |
| name | string | — | Updated name |
| prompt | string | — | Updated prompt |
| interval | string | — | New interval (recomputes next fire time) |
| assignedAgent | string | — | New agent runtime |
| agentProfile | string | — | New profile ID |
| heartbeatChecklist | ChecklistItem[] | — | Updated checklist (heartbeat type) |
| activeHoursStart | number | null | — | Updated active hours start |
| activeHoursEnd | number | null | — | Updated active hours end |
| activeTimezone | string | — | Updated timezone |
| heartbeatBudgetPerDay | number | null | — | Updated daily budget |
Errors:
400— Invalid interval, empty name/prompt, or runtime/profile incompatibility404— Schedule not found409— Invalid status transition (e.g. pausing an already paused schedule)
Pause a schedule to temporarily stop it from firing, then resume later:
// Pause a schedule — nextFireAt becomes null
await fetch('/api/schedules/sched-e7b2-1f4a', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'paused' }),
});
// Later, resume it — nextFireAt is recomputed
await fetch('/api/schedules/sched-e7b2-1f4a', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'active' }),
}); Delete Schedule
/api/schedules/{id} Permanently remove a schedule. Existing child tasks are not affected.
Response 200 — { "deleted": true }
Errors: 404 — Schedule not found
// Delete a schedule — child tasks remain intact
await fetch('/api/schedules/sched-e7b2-1f4a', { method: 'DELETE' }); Parse Interval
/api/schedules/parse Preview how an interval expression will be parsed. Returns the resolved cron expression, human-readable description, next 3 fire times, and confidence score. Useful for validating expressions before creating a schedule.
Request Body
| Field | Type | Req | Description |
|---|---|---|---|
| expression | string | * | Interval to parse: natural language, shorthand, or cron |
Response Body
| Field | Type | Req | Description |
|---|---|---|---|
| cronExpression | string | * | Resolved cron expression |
| description | string | * | Human-readable description |
| nextFireTimes | string[] | * | Next 3 fire times as ISO 8601 |
| confidence | number | * | Parse confidence (0.0–1.0) |
Errors: 400 — Expression missing or unparseable
Test how different interval formats resolve — useful for building a schedule creation form with live preview:
// Preview different interval formats before creating a schedule
const formats: string[] = ['every weekday at 9am', '30m', '0 */4 * * *'];
for (const expression of formats) {
const result: ParseResult = await fetch('/api/schedules/parse', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ expression }),
}).then(r => r.json());
console.log(`"${expression}" → ${result.cronExpression}`);
console.log(` ${result.description} (confidence: ${result.confidence})`);
console.log(` Next: ${result.nextFireTimes[0]}`);
} Example response:
{
"cronExpression": "0 9 * * 1-5",
"description": "At 09:00 AM, Monday through Friday",
"nextFireTimes": [
"2026-04-04T09:00:00.000Z",
"2026-04-07T09:00:00.000Z",
"2026-04-08T09:00:00.000Z"
],
"confidence": 0.95
} Get Heartbeat History
/api/schedules/{id}/heartbeat-history Retrieve recent heartbeat evaluation history including both action and suppressed entries. Only available for heartbeat-type schedules. Returns up to 50 most recent entries.
Response Body
| Field | Type | Req | Description |
|---|---|---|---|
| history | HistoryEntry[] | * | Array of heartbeat log entries |
| stats | object | * | Aggregate statistics for the heartbeat schedule |
History Entry
| Field | Type | Req | Description |
|---|---|---|---|
| id | string | * | Log entry ID |
| taskId | string | * | Associated task ID |
| event | enum | * | heartbeat_action or heartbeat_suppressed |
| payload | object | — | Event-specific payload data |
| timestamp | ISO 8601 | * | When the event occurred |
Stats Object
| Field | Type | Req | Description |
|---|---|---|---|
| suppressionCount | number | * | Total suppressions (no action taken) |
| lastActionAt | ISO 8601 | — | When the last action was taken |
| firingCount | number | * | Total heartbeat evaluations |
| heartbeatSpentToday | number | * | Budget spent today |
| heartbeatBudgetPerDay | number | — | Configured daily budget |
Errors:
400— Not a heartbeat schedule404— Schedule not found
Check heartbeat history to see how often the monitor takes action vs suppresses — useful for tuning checklist sensitivity:
// Review heartbeat history to tune monitoring sensitivity
const { history, stats }: HeartbeatHistory = await fetch('/api/schedules/sched-hb-3c9d/heartbeat-history')
.then(r => r.json());
// Calculate suppression ratio
const ratio: number = stats.suppressionCount / stats.firingCount;
console.log(`Action rate: ${((1 - ratio) * 100).toFixed(0)}%`);
console.log(`Budget: $${stats.heartbeatSpentToday.toFixed(2)} / $${stats.heartbeatBudgetPerDay?.toFixed(2) ?? 'unlimited'}`);
// Show recent entries
history.slice(0, 5).forEach(entry => {
const icon = entry.event === 'heartbeat_action' ? 'ACT' : 'SKIP';
console.log(` [${icon}] ${new Date(entry.timestamp).toLocaleTimeString()}`);
}); Example response:
{
"history": [
{
"id": "log-7a2b-4e1c",
"taskId": "task-f8d3-9b7e",
"event": "heartbeat_action",
"payload": { "triggeredChecks": ["errors", "latency"] },
"timestamp": "2026-04-03T14:30:00.000Z"
},
{
"id": "log-5c1d-8f3a",
"taskId": "task-a2e7-6c4b",
"event": "heartbeat_suppressed",
"payload": { "reason": "All checks passed" },
"timestamp": "2026-04-03T14:00:00.000Z"
}
],
"stats": {
"suppressionCount": 42,
"lastActionAt": "2026-04-03T14:30:00.000Z",
"firingCount": 48,
"heartbeatSpentToday": 1.85,
"heartbeatBudgetPerDay": 5.00
}
} Status Transitions
Schedules follow a strict lifecycle. Invalid transitions return 409.
| From | Allowed Targets |
|---|---|
| active | paused |
| paused | active |
| exhausted | (terminal — no transitions) |
Schedules move to exhausted automatically when firingCount reaches maxFirings or when expiresAt is passed.
Interval Formats
The interval field accepts three formats, tried in order:
| Format | Example | Description |
|---|---|---|
| Natural language | every weekday at 9am | Parsed via NLP with confidence score |
| Shorthand | 5m, 2h, 1d | Minutes, hours, or days |
| Cron expression | 0 9 * * 1-5 | Standard 5-field cron syntax |
Use the /api/schedules/parse endpoint to preview how any expression will be interpreted before creating a schedule.
Error Format
All errors follow a consistent JSON format:
{
"error": "Descriptive error message"
}