How Scheduled Routines with Cron Triggers Wake Paperclip Agents and Create Tracked Issues
Paperclip uses a minute-precision scheduler loop to evaluate cron expressions, claim due triggers with row-level locking, and chain together a RoutineRun record, a tracked issue, and an agent wake queue entry to execute scheduled automation.
Scheduled routines are a core automation primitive in Paperclip. When you configure a routine with a cron-style schedule, the platform transforms a simple time expression into a full execution pipeline: parsing the schedule, detecting fire times, persisting run state, creating audit-friendly issues, and waking the right agent to do the work. This article walks through the complete flow using the actual source code from paperclipai/paperclip.
How Cron Triggers Are Stored and Parsed
Paperclip stores schedule configuration in the routine_triggers table. Each row captures the cron expression, timezone, and computed next run time.
The Cron Parser Implementation
The Routine Service imports a lightweight parser from server/src/services/cron.ts on startup. The three key exports are:
parseCron(expression: string)– validates and converts a 5-field cron string into aParsedCronobjectvalidateCron(expression: string)– throws on malformed syntaxnextCronTickInTimeZone(expression, timezone, referenceTime)– computes the next fire instant
The parser handles standard cron semantics (minute, hour, day of month, month, day of week) and respects IANA timezone identifiers like "America/New_York" or "UTC".
// server/src/services/cron.ts
export interface ParsedCron {
minute: number[] | null; // null means wildcard
hour: number[] | null;
dayOfMonth: number[] | null;
month: number[] | null;
dayOfWeek: number[] | null;
}
export function nextCronTickInTimeZone(
expression: string,
timezone: string,
from: Date,
): Date;
The routine_triggers table schema includes:
| Column | Purpose |
|---|---|
cronExpression |
Raw 5-field string, e.g. "0 9 * * MON" |
timezone |
IANA zone, e.g. "Europe/London" |
nextRunAt |
Pre-computed UTC timestamp of next execution |
lastFiredAt |
When the trigger last dispatched |
enabled |
Boolean flag to pause without deleting |
The Scheduler Loop: Detecting and Claiming Due Triggers
The Routine Service in server/src/services/routines.ts starts a background loop that ticks once per minute. This design trades sub-minute precision for simplicity and database efficiency.
Querying for Due Triggers
Each iteration queries for triggers where nextRunAt ≤ now and enabled = true:
// server/src/services/routines.ts
const dueTriggers = await db
.select()
.from(routineTriggers)
.where(and(
eq(routineTriggers.companyId, companyId),
eq(routineTriggers.enabled, true),
lte(routineTriggers.nextRunAt, now),
));
Row-Level Locking for Safety
To prevent double-execution in multi-instance deployments, the scheduler claims each due trigger with FOR UPDATE:
await db.transaction(async (tx) => {
const [trigger] = await tx
.select()
.from(routineTriggers)
.where(eq(routineTriggers.id, triggerId))
.for('update') // row-level lock
.limit(1);
// Re-verify still due (defense against race)
if (!trigger || trigger.nextRunAt > now) return;
// ... proceed to create run, issue, wake ...
});
After processing, the scheduler calls nextCronTickInTimeZone again to compute and store the subsequent fire time.
Creating the RoutineRun and Tracked Issue
With a claimed trigger, the scheduler creates two linked records: a RoutineRun for execution state and an Issue for visibility and audit.
The RoutineRun Record
The routine_runs table captures:
| Field | Value for Scheduled Execution |
|---|---|
source |
"schedule" |
status |
"queued" (or "skipped" if project paused) |
triggerId |
FK to routine_triggers |
dispatchFingerprint |
Hash of the routine-dispatch payload |
triggeredAt |
Timestamp of scheduling decision |
const [run] = await tx.insert(routineRuns).values({
companyId,
routineId: trigger.routineId,
triggerId: trigger.id,
source: "schedule",
status: "queued",
triggeredAt: now,
}).returning();
Creating the Tracked Issue
Immediately after the run record, the service calls issueService.createIssue from server/src/services/issues.ts:
const issue = await issueSvc.createIssue({
title: `${routine.title} – scheduled run`,
originKind: "routine_execution",
originId: routine.id,
linkedIssueId: null, // top-level issue, not a sub-issue
priority: routine.priority,
description: routine.description,
});
The generated issue receives:
- Type:
"routine_execution" - Identifier: Auto-generated like
R-00123 - Link to run: The issue's
linkedIssueIdfield connects to theRoutineRunrow - Inheritance: Priority and title from the routine definition
This creates an auditable, trackable work item that appears in the Paperclip board, search, and activity feed.
Waking the Assigned Agent
The final scheduler step enqueues a wake so an agent actually executes the routine. This decouples scheduling from assignment and execution.
The Wake Queue Mechanism
The queueIssueAssignmentWakeup function in server/src/services/issue-assignment-wakeup.ts inserts a record into heartbeat_runs:
export async function queueIssueAssignmentWakeup(
{ issueId, source, wakeAt }: {
issueId: string;
source: "scheduled" | "manual";
wakeAt: Date;
},
db: Db,
) {
await db.insert(heartbeatRuns).values({
companyId,
issueId,
source, // "scheduled" for cron triggers
status: "queued",
scheduledAt: wakeAt,
actorId: source === "scheduled"
? "routine-scheduler" // system identifier
: "manual",
});
}
The heartbeat_runs entry serves as a durable wake signal. The Issue Assignment worker polls this table, selects an eligible agent (routine's assigneeAgentId or company default), and attaches them to the issue.
Agent Execution Flow
Once woken via long-polling stream, the agent:
- Receives the wake with issue and run context
- Starts the routine's execution workspace
- Updates
RoutineRun.statusto"running" - Executes the routine's defined steps
- On completion, sets status to
"completed"or"failed" - Updates the linked issue and writes
routine.run_completedorroutine.run_failedto the activity log
Complete Scheduler Transaction
Here's the full transactional flow from server/src/services/routines.ts:
for (const trigger of dueTriggers) {
// Compute next fire time before transaction
const nextRunAt = nextCronTickInTimeZone(
trigger.cronExpression!,
trigger.timezone!,
now
);
await db.transaction(async (tx) => {
// 1. Create the run record
const [run] = await tx.insert(routineRuns).values({
companyId,
routineId: trigger.routineId,
triggerId: trigger.id,
source: "schedule",
status: "queued",
triggeredAt: now,
}).returning();
// 2. Create tracked issue for visibility
const issue = await issueSvc.createIssue({
title: `${routine.title} – scheduled run`,
originKind: "routine_execution",
originId: routine.id,
linkedIssueId: null,
priority: routine.priority,
});
// 3. Wake the assigned agent
await queueIssueAssignmentWakeup({
issueId: issue.id,
source: "scheduled",
wakeAt: now,
}, tx);
// 4. Advance the trigger to next fire time
await tx.update(routineTriggers).set({
lastFiredAt: now,
nextRunAt,
}).where(eq(routineTriggers.id, trigger.id));
});
}
UI Visibility and Human-Readable Schedules
Users interact with scheduled routines through two UI affordances.
Cron Expression Display
The ui/src/lib/cron-readable.ts module converts expressions to friendly text:
| Expression | Display |
|---|---|
0 * * * * |
"Every hour" |
0 9 * * MON |
"Every Monday at 9:00 AM" |
*/15 8-17 * * 1-5 |
"Every 15 minutes, Monday through Friday, between 8:00 AM and 5:00 PM" |
Activity Feed Integration
The Activity Log service (server/src/services/activity-log.ts) records system events:
routine.run_queued– scheduler created the runroutine.run_started– agent began executionroutine.run_completed/routine.run_failed– final disposition
These appear in the issue timeline and project activity feed.
Defining a Scheduled Routine
Here's the complete client-side type and API call:
// shared/types.ts
export type RoutineTrigger = {
kind: "schedule";
label: string; // Display name, e.g. "Morning Check"
enabled: boolean;
cronExpression: string; // "0 9 * * *"
timezone: string; // "America/Los_Angeles"
};
curl -X POST http://localhost:3100/api/routines \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Daily Security Scan",
"description": "Run security analysis on all repositories.",
"priority": "high",
"triggers": [{
"kind": "schedule",
"label": "Daily 9am",
"enabled": true,
"cronExpression": "0 9 * * *",
"timezone": "UTC"
}],
"assigneeAgentId": "agent-security-001"
}'
Summary
- Cron parsing happens in
server/src/services/cron.ts, which validates expressions and computes next fire times with timezone awareness. - The scheduler loop in
server/src/services/routines.tsruns minutely, queries due triggers with row-level locking, and advancesnextRunAtatomically. - RoutineRun records capture execution state with
source: "schedule"and link back to their triggering configuration. - Tracked issues of type
routine_executionprovide audit visibility, inherit routine metadata, and receive auto-generated identifiers. - Agent wakes are enqueued via
queueIssueAssignmentWakeupinserver/src/services/issue-assignment-wakeup.ts, using theheartbeat_runstable as a durable signal. - The complete flow is transactional: run, issue, wake, and trigger update succeed or fail together.
Frequently Asked Questions
What happens if the scheduler misses a trigger time?
The nextRunAt comparison uses ≤ now, so overdue triggers fire immediately when the scheduler resumes. The row-level lock prevents duplicate execution. If a project is paused, the run status is set to "skipped" rather than "queued".
Can scheduled routines run sub-minute intervals?
No. The scheduler loop ticks once per minute by design. For sub-minute automation, use webhook triggers or the API to invoke routines on demand.
How does Paperclip handle daylight saving time transitions?
The nextCronTickInTimeZone function in server/src/services/cron.ts uses the IANA timezone database. Clock jumps forward skip missed runs; clock jumps backward can cause duplicate nextRunAt values, which the FOR UPDATE lock and re-verification logic prevent from double-executing.
Where can I see scheduled run history?
The Activity Feed shows routine.run_queued, routine.run_started, and completion events. Each scheduled run also creates a tracked issue visible in the board with filterable type routine_execution.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →