# How Scheduled Routines with Cron Triggers Wake Paperclip Agents and Create Tracked Issues

> Learn how Paperclip uses cron triggers and its scheduler loop to wake agents and create tracked issues for automated workflows. Discover efficient scheduled automation.

- Repository: [Paperclip/paperclip](https://github.com/paperclipai/paperclip)
- Tags: how-to-guide
- Published: 2026-08-18

---

**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`](https://github.com/paperclipai/paperclip/blob/main/server/src/services/cron.ts) on startup. The three key exports are:

- `parseCron(expression: string)` – validates and converts a 5-field cron string into a `ParsedCron` object
- `validateCron(expression: string)` – throws on malformed syntax
- `nextCronTickInTimeZone(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"`.

```ts
// 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`](https://github.com/paperclipai/paperclip/blob/main/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`:

```ts
// 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`:

```ts
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 |

```ts
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`](https://github.com/paperclipai/paperclip/blob/main/server/src/services/issues.ts):

```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 `linkedIssueId` field connects to the `RoutineRun` row
- **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`](https://github.com/paperclipai/paperclip/blob/main/server/src/services/issue-assignment-wakeup.ts) inserts a record into `heartbeat_runs`:

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

1. Receives the wake with issue and run context
2. Starts the routine's **execution workspace**
3. Updates `RoutineRun.status` to `"running"`
4. Executes the routine's defined steps
5. On completion, sets status to `"completed"` or `"failed"`
6. Updates the linked issue and writes `routine.run_completed` or `routine.run_failed` to the activity log

## Complete Scheduler Transaction

Here's the full transactional flow from [`server/src/services/routines.ts`](https://github.com/paperclipai/paperclip/blob/main/server/src/services/routines.ts):

```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`](https://github.com/paperclipai/paperclip/blob/main/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`](https://github.com/paperclipai/paperclip/blob/main/server/src/services/activity-log.ts)) records system events:

- `routine.run_queued` – scheduler created the run
- `routine.run_started` – agent began execution
- `routine.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:

```ts
// 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"
};

```

```bash
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`](https://github.com/paperclipai/paperclip/blob/main/server/src/services/cron.ts), which validates expressions and computes next fire times with timezone awareness.
- **The scheduler loop** in [`server/src/services/routines.ts`](https://github.com/paperclipai/paperclip/blob/main/server/src/services/routines.ts) runs minutely, queries due triggers with row-level locking, and advances `nextRunAt` atomically.
- **RoutineRun records** capture execution state with `source: "schedule"` and link back to their triggering configuration.
- **Tracked issues** of type `routine_execution` provide audit visibility, inherit routine metadata, and receive auto-generated identifiers.
- **Agent wakes** are enqueued via `queueIssueAssignmentWakeup` in [`server/src/services/issue-assignment-wakeup.ts`](https://github.com/paperclipai/paperclip/blob/main/server/src/services/issue-assignment-wakeup.ts), using the `heartbeat_runs` table 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`](https://github.com/paperclipai/paperclip/blob/main/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`.