# How Routines and Scheduled Tasks Work in Paperclip: Cron and Triggers Explained

> Discover how Paperclip uses cron and triggers for scheduled automation. Learn about its custom JavaScript scheduler for efficient routine execution.

- Repository: [Paperclip/paperclip](https://github.com/paperclipai/paperclip)
- Tags: internals
- Published: 2026-08-16

---

**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/main/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/main/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):

```typescript
// 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:
1. **Validates** the cron string via `validateCron` and timezone via `assertTimeZone`
2. **Floors** the start date to the next whole minute
3. **Scans forward** minute-by-minute (up to one-year limit) until `matchesCronMinute` returns 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/main/server/src/services/routines.ts)](https://github.com/paperclipai/paperclip/blob/master/server/src/services/routines.ts), lines 44-87):

```typescript
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:

```typescript
await dispatchRoutineRun({
  routine,
  trigger,           // The routine_trigger row for scheduled runs
  source: "schedule", // or "manual", "webhook", "api"
  payload: {},        // Optional user-provided values
});

```

Internal steps:
1. **Agent resolution** — validates `assigneeAgentId` is assignable
2. **Variable resolution** — merges routine defaults, automatic workspace variables, and payload via `resolveRoutineVariableValues()`
3. **Template interpolation** — substitutes `{{var}}` placeholders in title/description
4. **Payload merging** — combines user payload with resolved variables via `mergeRoutineRunPayload()`
5. **Issue creation** — creates or coalesces with existing live issue; stores `routine_run` with `dispatchFingerprint` for 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

```typescript
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/main/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

```typescript
await dispatchRoutineRun({
  routine,
  trigger: null,           // No schedule trigger attached
  source: "manual",
  payload: { customer_name: "Acme" },
});

```

### Cron Parsing Utility

```typescript
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/main/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

```typescript
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/main/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/main/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/main/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/main/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/main/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` + `timezone` columns with pre-calculated `nextRunAt` timestamps
- **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.