Schedule Simulation

scheduling.simulate() runs the planner on a hypothetical plan, or on a live job with changes applied, and returns the schedule — without persisting anything and without emitting events. Use it for instant what-if comparisons.

Planning v2 availability. Merged to main and deployed to production (platform.clockwork-co.com; F1 to F7 and F9 to F11 on 2026-10-09, F12 pool assignment and the security fixes on 2026-10-12). The platform flag CLOCKWORK_PLANNING_V2 is currently on globally, in production and in preview, as of 2026-10-12: every organization gets planning-v2 behavior, and with the flag off the planning-v2 endpoints answer 404. Per-organization gating (CLOCKWORK_PLANNING_V2=orgs) is merged but not enabled in production, so the global flag is the only access control in effect. Exact dates and commits: Changelog.

The endpoint is 404 while the platform flag CLOCKWORK_PLANNING_V2 is off.

Every example here is a print shop: a press with 2 slots, print runs that each need both slots, and a binding step. Runnable: docs-examples/guide-simulate.mts.

1. A hypothetical graph

The body is exactly what createWithGraph takes (plus an optional now), and data.schedule is exactly the schedule creating it would return (same planner, same code path). data.mode is 'graph'.

typescript
// Two print runs that each need BOTH slots of a press, then one binding step.
function shop(pressSlots: number): CreateWorkflowGraphInput {
  const needsPress = [{ resourceRef: 'press', units: 2 }]
  return {
    instance: { name: 'What-if', resourceLeveling: 'delay' },
    resources: [{ ref: 'press', name: 'Press', type: 'asset', capacity: pressSlots }],
    tasks: [
      { ref: 'covers', name: 'Print covers', durationMinutes: 90, requirements: needsPress },
      { ref: 'inside', name: 'Print inside pages', durationMinutes: 120, requirements: needsPress },
      { ref: 'bind', name: 'Bind', durationMinutes: 60 },
    ],
    dependencies: [{ from: 'covers', to: 'bind' }, { from: 'inside', to: 'bind' }],
  }
}
 
const now = new Date().toISOString() // pin the planning instant so both runs are comparable
const two = await client.scheduling.simulate({ ...shop(2), now })
const four = await client.scheduling.simulate({ ...shop(4), now })
 
two.data.schedule.makespanMinutes   // 270: the runs take turns (90 + 120), then bind (60)
four.data.schedule.makespanMinutes  // 180: the runs go side by side (120), then bind (60)

The schedule carries the same fields as a creation result:

typescript
data.schedule.requiredStart     // when work must begin
data.schedule.makespanMinutes
data.schedule.criticalPath      // the chain that determines the finish
data.schedule.utilization       // per resource: occupied vs available, peak units
data.schedule.conflicts

Compare options by calling it several times (2 slots vs 4, a different anchor) — each is a pure computation. resourceLeveling: 'delay' is what makes the two runs take turns on a 2-slot press; with the default 'off' they would overlap and the response would carry a resource_capacity_breach conflict (see Resource Leveling).

2. A live job plus changes

typescript
const { data } = await client.scheduling.simulate({
  baseInstanceId: job.id,
  mutations: [
    { type: 'delay', taskId: inside.id, delayMinutes: 45 },  // the inside pages start late
    { type: 'add_resource', resourceId: pressId, units: 2 }, // a second press arrives
  ],
})
 
data.mode                     // 'what_if'
data.baseline                 // summary of the live plan: requiredStart, finish, makespanMinutes, conflictCount
data.schedule                 // the simulated schedule
data.changes                  // every task whose planned window moves: previous -> current
data.makespanDeltaMinutes     // simulated minus live (here -45)
MutationEffect
delaylike delay_task: by delayMinutes or to newEarliestStart; not allowed on done/cancelled tasks (422 INVALID_MUTATION)
completemarks a task done at at (default now) and pins it
add_resource / remove_resourceadd or remove units (default 1) of capacity on a resource or pool that the job's tasks claim with requirements; capacity never goes below 0

A what-if takes 1 to 100 mutations, and the job must have at least one task. The live schedule is never changed. A mutation that cannot be applied is a 422: an unknown task is UNKNOWN_REF ("Task … is not in this instance"), as is a capacity change on a resource with no capacity limit in the job, and a finished task is INVALID_MUTATION. A plan that cannot be met is a normal 200 with conflicts. In what-if mode schedule.tasks[].ref holds the task id, because the live job's refs are not stored.

Known limitation. In what-if mode add_resource / remove_resource change capacities only; they do not add or remove members of a pool, so they cannot preview a helper joining or leaving a job that uses an assignmentPolicy. (Graph-form simulation with the policy returns schedule.assignments.)

Known limitation. In what-if mode the simulated plan is built from resource capacities only. A resource's availability windows and shared-state settings are applied to the baseline but not to the simulated plan, so a job that uses them can show tasks moving into unavailable time. Prefer graph-form simulation for such jobs.

Guarantees and limits

  • No writes, no events, no recompute count. The service layer has no write path, and tests assert the tables are unchanged. The HTTP layer's usual request log (usage_logs) still records that the call happened.
  • Separate rate limit. Simulation is charged to its own bucket at 5× the API key's per-minute budget (default key: 5,000/min) and does not consume the shared budget. The RateLimit-* headers describe that bucket.
  • Deterministic. The same input and now give the same schedule. Pass now to pin the planning instant.
  • Unknown fields are rejected. A graph body is validated like createWithGraph (400 VALIDATION_ERROR for an unknown field); a what-if body accepts only baseInstanceId, mutations and now.