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
mainand 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 flagCLOCKWORK_PLANNING_V2is 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 answer404. 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'.
// 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:
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.conflictsCompare 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
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)| Mutation | Effect |
|---|---|
delay | like delay_task: by delayMinutes or to newEarliestStart; not allowed on done/cancelled tasks (422 INVALID_MUTATION) |
complete | marks a task done at at (default now) and pins it |
add_resource / remove_resource | add 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_resourcechange 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 anassignmentPolicy. (Graph-form simulation with the policy returnsschedule.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
nowgive the same schedule. Passnowto pin the planning instant. - Unknown fields are rejected. A graph body is validated like
createWithGraph(400 VALIDATION_ERRORfor an unknown field); a what-if body accepts onlybaseInstanceId,mutationsandnow.