How Routines and Scheduled Tasks Work in Paperclip: Cron and Triggers Explained
Paperclip implements scheduled automation through company-scoped routines triggered by cron expressions, using a custom JavaScript scheduler that runs inside the API server rather than relying on OS-level cron daemons.
The Paperclip codebase (paperclipai/paperclip) treats routines as reusable automations that can be started manually, via API/webhook, or on a schedule defined by a cron expression stored in the routine_triggers table. This article breaks down the complete implementation—from database schema to the heartbeat tick loop that fires scheduled runs.
Core Data Model for Routines and Triggers
Three tables power the scheduling system, defined in [packages/db/src/schema/routines.ts](https://github.com/paperclipai/paperclip/blob/master/packages/db/src/schema/routines.ts):
| Table | Purpose |
|---|---|
routines |
Stores the routine definition including title, variables, catchUpPolicy, and activityGatePolicy |
routine_triggers |
Holds trigger configuration; kind = "schedule" rows contain cronExpression, timezone, and nextRunAt (lines 106-136) |
routine_runs |
Records each execution with source (schedule or manual), status, and linked issue |
The routine_triggers table captures the cron expression, IANA timezone, and next scheduled fire time—enabling per-trigger timezone support rather than server-wide configuration.
Cron Parsing and Next-Tick Calculation
Paperclip contains a pure-JavaScript cron parser located in [server/src/services/routines.ts](https://github.com/paperclipai/paperclip/blob/master/server/src/services/routines.ts). The key helper is nextCronTickInTimeZone() (lines 25-42):
// Validates cron string and timezone, then scans minute-by-minute
// Returns first matching Date or null if none found within one year
const nextRun = nextCronTickInTimeZone(
"0 9 * * 1-5", // 9 AM weekdays
"Europe/London",
new Date(),
);
The algorithm:
- Validates the cron string via
validateCronand timezone viaassertTimeZone - Floors the start date to the next whole minute
- Scans forward minute-by-minute (up to one-year limit) until
matchesCronMinutereturns true
A secondary helper, isSubHourlyCronExpression() (lines 45-58), detects whether a cron fires more than once per hour—this affects the catch-up policy behavior described below.
The Scheduler Tick Loop
Paperclip does not use an OS cron daemon. Instead, a lightweight heartbeat process periodically calls tickScheduledTriggers(now) in the API server.
Querying Due Triggers
The core query (from [server/src/services/routines.ts](https://github.com/paperclipai/paperclip/blob/master/server/src/services/routines.ts), lines 44-87):
const due = await db
.select({
trigger: routineTriggers,
routine: routines,
projectPausedAt: projects.pausedAt,
})
.from(routineTriggers)
.innerJoin(routines, eq(routineTriggers.routineId, routines.id))
.leftJoin(projects, eq(routines.projectId, projects.id))
.where(and(
eq(routineTriggers.kind, "schedule"),
eq(routineTriggers.enabled, true),
eq(routines.status, "active"),
isNotNull(routineTriggers.nextRunAt),
lte(routineTriggers.nextRunAt, now),
))
.orderBy(asc(routineTriggers.nextRunAt), asc(routineTriggers.createdAt));
Processing Each Due Trigger
For each due row, tickScheduledTriggers executes this pipeline:
| Step | Logic |
|---|---|
| Eligibility check | Skips if project is paused or work-tree execution cutoff suppresses automatic runs; still advances nextRunAt |
| Catch-up handling | If catchUpPolicy === "enqueue_missed_with_cap", fires multiple runs up to MAX_CATCH_UP_RUNS; sub-hourly schedules fire once |
| Atomic claim | UPDATE … WHERE nextRunAt = row.trigger.nextRunAt ensures only one scheduler instance claims the trigger |
| Activity gating | If require_external_activity is set and gate fails, records run as suppressed |
| Dispatch | Calls dispatchRoutineRun() to create the execution |
Dispatching a Routine Run
dispatchRoutineRun() (lines 1660-1676) handles the actual execution:
await dispatchRoutineRun({
routine,
trigger, // The routine_trigger row for scheduled runs
source: "schedule", // or "manual", "webhook", "api"
payload: {}, // Optional user-provided values
});
Internal steps:
- Agent resolution — validates
assigneeAgentIdis assignable - Variable resolution — merges routine defaults, automatic workspace variables, and payload via
resolveRoutineVariableValues() - Template interpolation — substitutes
{{var}}placeholders in title/description - Payload merging — combines user payload with resolved variables via
mergeRoutineRunPayload() - Issue creation — creates or coalesces with existing live issue; stores
routine_runwithdispatchFingerprintfor idempotency
The source: "schedule" value renders with a scheduled badge in the UI.
Catch-Up and Activity Gate Policies
Routines define two policies stored on the routines table:
| Policy | Options | Effect |
|---|---|---|
catchUpPolicy |
enqueue_missed_with_cap / skip_missed |
Replay missed windows (capped at MAX_CATCH_UP_RUNS) or ignore them |
activityGatePolicy |
require_external_activity / none |
Block scheduled runs when no external activity recorded in required window; activityGateScope defines the time window |
Sub-hourly schedules receive special handling: even with enqueue_missed_with_cap, they fire only once to prevent runaway execution.
Creating Scheduled Triggers
API Example
import { nextCronTickInTimeZone } from "./routines.js";
await db.insert(routineTriggers).values({
companyId,
routineId,
kind: "schedule",
label: "Weekly digest",
cronExpression: "0 14 * * 1-5", // Mon-Fri at 14:00
timezone: "America/New_York",
nextRunAt: nextCronTickInTimeZone(
"0 14 * * 1-5",
"America/New_York",
new Date(),
),
enabled: true,
});
See the same pattern in [ui/src/pages/RoutineDetail.tsx](https://github.com/paperclipai/paperclip/blob/master/ui/src/pages/RoutineDetail.tsx) lines 485-500 for the UI save handler.
Manual Trigger Example
await dispatchRoutineRun({
routine,
trigger: null, // No schedule trigger attached
source: "manual",
payload: { customer_name: "Acme" },
});
Cron Parsing Utility
import { nextCronTickInTimeZone } from "./routines.js";
const next = nextCronTickInTimeZone(
"0 12 1 * *", // First day of month at noon
"Pacific/Kiritimati", // UTC+14 timezone
new Date("2026-06-01T12:01:00Z"),
);
// → 2026-07-01T12:00:00+14:00
Unit tests in [routines-formatter-cache.test.ts](https://github.com/paperclipai/paperclip/blob/master/server/src/services/routines-formatter-cache.test.ts) validate this behavior.
Scheduler Heartbeat Setup
async function heartbeat() {
const { triggered } = await routineService.tickScheduledTriggers(new Date());
console.log(`Scheduler fired ${triggered} routine(s)`);
}
setInterval(heartbeat, 60_000); // Tick every minute
Key Implementation Files
| File | Role |
|---|---|
[server/src/services/routines.ts](https://github.com/paperclipai/paperclip/blob/master/server/src/services/routines.ts) |
Core service—cron parsing, trigger ticking, run dispatch |
[packages/db/src/schema/routines.ts](https://github.com/paperclipai/paperclip/blob/master/packages/db/src/schema/routines.ts) |
Database schema for routines, triggers, and runs |
[ui/src/pages/RoutineDetail.tsx](https://github.com/paperclipai/paperclip/blob/master/ui/src/pages/RoutineDetail.tsx) |
Frontend for viewing/editing schedule triggers |
[ui/src/lib/cron-fires.ts](https://github.com/paperclipai/paperclip/blob/master/ui/src/lib/cron-fires.ts) |
UI helpers for cron validation and next-fire explanation |
[server/src/services/routines-formatter-cache.test.ts](https://github.com/paperclipai/paperclip/blob/master/server/src/services/routines-formatter-cache.test.ts) |
Unit tests for cron formatting and caching |
Summary
- Routines are company-scoped automations stored with variables and execution policies
- Scheduled triggers use
cronExpression+timezonecolumns with pre-calculatednextRunAttimestamps - Custom JS scheduler replaces OS cron—
tickScheduledTriggers()runs on a heartbeat interval - Atomic claiming via
UPDATE … WHERE nextRunAt = ?prevents double-execution across multiple server instances - Catch-up policies (
enqueue_missed_with_cap/skip_missed) and activity gates (require_external_activity) provide fine-grained control over when scheduled runs actually execute - Source tracking (
schedule/manual/api/webhook) enables proper UI badging and audit trails
Frequently Asked Questions
Does Paperclip use the system crontab for scheduling?
No. Paperclip implements scheduling entirely in JavaScript. A heartbeat process in the API server calls tickScheduledTriggers() at regular intervals (typically every 60 seconds), queries for due triggers, and dispatches runs. This design enables multi-instance safety through atomic database updates and per-trigger timezone support.
How does Paperclip prevent duplicate scheduled runs?
Through atomic claiming. The scheduler issues UPDATE … WHERE nextRunAt = row.trigger.nextRunAt for each trigger. If another process already advanced the cursor, the update returns zero rows and the current process skips that trigger. A dispatchFingerprint on the routine_run row provides additional idempotency protection.
What happens if a scheduled run is missed?
Behavior depends on the routine's catchUpPolicy. With enqueue_missed_with_cap, the scheduler fires multiple runs to fill missed windows, capped at MAX_CATCH_UP_RUNS. With skip_missed, missed windows are silently ignored. Sub-hourly schedules always fire once regardless of policy to prevent runaway execution.
Can scheduled runs be blocked based on external activity?
Yes. Set activityGatePolicy to require_external_activity and define an activityGateScope. The scheduler checks for external activity in the required window; if none is found, the run is recorded as suppressed rather than executed. This prevents noisy automations during quiet periods.
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 →