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 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".

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

  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:

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

// 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.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, 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 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →