Schedule a whole workflow in one call
workflowInstances.createWithGraph creates a job (resources, tasks, dependencies and the rules for scheduling them) in one atomic request and hands back the complete schedule. This guide walks through one realistic job: a print order due at a fixed time.
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.
Every endpoint used here answers 404 while the platform flag CLOCKWORK_PLANNING_V2 is off.
The whole walk-through is one runnable file, docs-examples/guide-print-shop.mts. The field-by-field reference is on the Workflow Instances page.
The scenario
- A press with 2 slots.
- Two print runs (covers, inside pages) that each need both slots, so they cannot overlap.
- One operator, needed only to load the press (the first 10 minutes of a run) and unload it (the last 10 minutes). In between the operator is free. That is a bookends claim.
- Plates must be made before either run, and binding follows both. Everything is due at 16:00 UTC, three days from now: an anchor, so we plan backward from it.
resourceLeveling: 'delay', so the planner resolves press contention by delaying tasks instead of just reporting it.
1. Describe the plan
const due = new Date()
due.setUTCDate(due.getUTCDate() + 3)
due.setUTCHours(16, 0, 0, 0)
function plan(resourceLeveling: 'off' | 'delay'): CreateWorkflowGraphInput {
return {
instance: {
name: `Print order ${suffix}`,
anchor: { type: 'finish', at: due.toISOString() },
schedulingDirection: 'backward',
resourceLeveling,
},
resources: [
{ ref: 'press', name: `Press ${suffix}`, type: 'asset', capacity: 2 },
{ ref: 'operator', name: `Operator ${suffix}`, type: 'human', capacity: 1 },
],
tasks: [
{ ref: 'plates', name: 'Make plates', durationMinutes: 30,
requirements: [{ resourceRef: 'operator', units: 1 }] },
{ ref: 'run-cover', name: 'Print covers', durationMinutes: 90,
requirements: [
{ resourceRef: 'press', units: 2 },
{ resourceRef: 'operator', units: 1, occupancy: { mode: 'bookends', startMinutes: 10, endMinutes: 10 } },
] },
{ ref: 'run-inside', name: 'Print inside pages', durationMinutes: 120,
requirements: [
{ resourceRef: 'press', units: 2 },
{ resourceRef: 'operator', units: 1, occupancy: { mode: 'bookends', startMinutes: 10, endMinutes: 10 } },
] },
{ ref: 'bind', name: 'Bind and box', durationMinutes: 60,
requirements: [{ resourceRef: 'operator', units: 1 }] },
],
dependencies: [
{ from: 'plates', to: 'run-cover' },
{ from: 'plates', to: 'run-inside' },
{ from: 'run-cover', to: 'bind' },
{ from: 'run-inside', to: 'bind' },
],
}
}
const ms = (iso: string) => new Date(iso).getTime()
const peak = (s: { utilization: Array<{ resourceRef?: string; peakUnits: number }> }, ref: string) =>
s.utilization.find((u) => u.resourceRef === ref)?.peakUnitsWhat each piece does:
anchoris the required finish of the whole plan.schedulingDirection: 'backward'places every task at its latest start that still meets it (and needs the anchor, or the request is a422 ANCHOR_REQUIRED).requirementsare what make a task claim capacity.units: 2on a press withcapacity: 2takes both slots.occupancy: { mode: 'bookends', ... }holds the operator only for the first and last minutes. (resourceRefswould only link a resource to a task without claiming anything.)- A requirement names a specific resource or a pool. To have the planner pick "any free operator" from a
capability:pool, setassignmentPolicy(see Assign from a pool); otherwise a pool is only a total capacity. - Asking for more units than the resource has (
units: 3on the press) is rejected up front with422 REQUIREMENT_EXCEEDS_CAPACITY.
2. Preview it with a simulation (optional)
scheduling.simulate takes the same body and creates nothing. Run the plan with leveling off first to see what leveling is for:
const off = await client.scheduling.simulate(plan('off'))
if (off.data.mode !== 'graph') throw new Error('expected graph mode')
if (peak(off.data.schedule, 'press') !== 4) throw new Error(`leveling off: expected press peak 4, got ${peak(off.data.schedule, 'press')}`)
if (!off.data.schedule.conflicts.some((c) => c.type === 'resource_capacity_breach')) throw new Error('expected a resource_capacity_breach conflict with leveling off')
console.log('off : press peak', peak(off.data.schedule, 'press'), off.data.schedule.conflicts.map((c) => c.type))With resourceLeveling: 'off' (the default) the planner only reports over-allocation: both runs are placed at the same time, the press peaks at 4 slots in use out of 2, and two resource_capacity_breach conflicts are reported. Nothing is moved.
3. Create it
const { data } = await client.workflowInstances.createWithGraph(plan('delay'))
const { schedule, refs } = data
const at = (ref: string) => schedule.tasks.find((t) => t.ref === ref)!With 'delay' the runs take turns, the press never exceeds 2 slots, and the response has no conflicts. On the run behind this guide the schedule was (your times follow your anchor):
plates 11:00 -> 11:30
run-cover 11:30 -> 13:00
run-inside 13:00 -> 15:00
bind 15:00 -> 16:00 (ends exactly at the anchor)
requiredStart 11:00, makespanMinutes 3004. Read the schedule
data.schedule holds everything; data.refs and data.resourceRefs map your refs to the ids the platform generated.
| Read | What it tells you |
|---|---|
schedule.tasks[] | Per task: plannedStart / plannedEnd (where it was placed), latestStart, slackSec, isCritical. |
schedule.requiredStart | When the first task must begin: the earliest plannedStart. In a backward plan, the latest moment the job can start and still meet the anchor. |
schedule.makespanMinutes | Minutes from requiredStart to the last planned end (here 300: 30 + 90 + 120 + 60). |
schedule.finish | The last planned end. Equal to the anchor here. |
schedule.criticalPath | Task ids of the chain that determines the finish. |
schedule.utilization[] | Per resource: occupiedUnitMinutes, availableUnitMinutes, peakUnits. |
schedule.conflicts[] | Everything the plan could not satisfy. |
utilization shows bookends at work: the operator is occupied 130 unit-minutes (30 for plates, 60 for binding, and only 2 x 20 for the two runs), not the 210 minutes the runs last, and the press is occupied 420 (2 slots x 210 minutes).
if (schedule.conflicts.length) throw new Error('unexpected conflicts: ' + JSON.stringify(schedule.conflicts))
if (peak(schedule, 'press') !== 2) throw new Error('press must never exceed 2 slots')
// The two runs were serialised: one ends before the other starts.
const [first, second] = [at('run-cover'), at('run-inside')].sort((a, b) => ms(a.plannedStart) - ms(b.plannedStart))
if (ms(first!.plannedEnd) > ms(second!.plannedStart)) throw new Error('runs overlap')
// Backward from the anchor: the last task ends exactly at the due time.
if (schedule.finish !== due.toISOString()) throw new Error('finish should equal the anchor, got ' + schedule.finish)
if (at('bind').plannedEnd !== schedule.finish) throw new Error('bind should end at the anchor')
if (ms(at('plates').plannedEnd) > ms(first!.plannedStart)) throw new Error('plates must be done before the first run')
// Bookends: the operator is held 30 + 60 minutes for plates and binding, plus only the first and last
// 10 minutes of each run (2 x 20), not the 210 minutes the runs last.
const usage = (ref: string) => schedule.utilization.find((u) => u.resourceRef === ref)!
if (usage('operator').occupiedUnitMinutes !== 130) throw new Error('operator should be occupied 130 unit-minutes, got ' + usage('operator').occupiedUnitMinutes)
if (usage('press').occupiedUnitMinutes !== 420) throw new Error('press should be occupied 420 unit-minutes')
// requiredStart is the earliest planned start; makespan is requiredStart -> finish.
if (schedule.requiredStart !== at('plates').plannedStart) throw new Error('requiredStart should be the plates start')
if (schedule.makespanMinutes !== Math.round((ms(schedule.finish!) - ms(schedule.requiredStart!)) / 60_000)) throw new Error('makespan mismatch')
if (data.instance.requiredStart !== schedule.requiredStart || data.instance.makespanMinutes !== schedule.makespanMinutes) throw new Error('instance must carry the same values')
if (data.instance.schedulingDirection !== 'backward' || data.instance.resourceLeveling !== 'delay' || data.instance.anchor?.at !== due.toISOString()) {
throw new Error('instance did not store anchor/direction/leveling')
}The same requiredStart and makespanMinutes are stored on the instance (data.instance), together with the anchor, schedulingDirection and resourceLeveling you set. Definitions of both fields are in the reference.
5. Handle conflicts
A plan that cannot be met is still a 201. The planner returns the best schedule it can and lists what failed in schedule.conflicts. Always check it:
type | Meaning |
|---|---|
anchor_infeasible | The plan cannot finish by the anchor. shortfallMinutes says by how much. The schedule falls back to starting as early as the tasks' lower bounds allow. |
resource_capacity_breach | A resource is over-allocated. Seen with resourceLeveling: 'off'; 'delay' is what resolves it. |
deadline_violated | A task cannot finish by its deadline. |
constraint_violated | A task falls outside an anchorWindow or other constraint window. |
max_lag_exceeded | A dependency's maxLagMinutes could not be kept. |
Conflicts are also stored, so you can list them later with conflicts.list() (see the delay below).
6. Apply a delay
When a task slips, tell the platform; the job is re-planned as a whole and the stored requiredStart, makespanMinutes, task windows and conflicts are refreshed. Each recompute emits one schedule.changed event listing every task whose planned window moved (see Webhooks).
// Backward plans keep slack: pushing "plates" back 60 minutes still leaves room before the first run.
const platesBefore = at('plates').plannedStart
await client.tasks.delay(refs.plates!, { delayMinutes: 60, reason: 'plates arrived late' })
const stillFits = (await client.tasks.get(refs.plates!)).data
if (stillFits.plannedStart !== platesBefore) throw new Error('a delay inside the slack must not move the plan')
// Pushing it to 2 hours before the anchor cannot work: the runs and binding need 5 hours.
const tooLate = new Date(due.getTime() - 2 * 3_600_000).toISOString()
await client.tasks.delay(refs.plates!, { newEarliestStart: tooLate })
const open = (await client.conflicts.list({ resolved: false })).data
const mine = new Set(Object.values(refs))
const infeasible = open.filter((c) => mine.has(c.taskId) && c.type === 'anchor_infeasible')
if (infeasible.length !== 1) throw new Error('expected one anchor_infeasible conflict, got ' + infeasible.length)
console.log('delay too late ->', infeasible[0]!.description)
const after = (await client.workflowInstances.get(data.instance.id)).data
if (after.requiredStart !== tooLate) throw new Error('requiredStart should now be the delayed start, got ' + after.requiredStart)Two things to notice:
- A backward plan keeps slack. Pushing "plates" back an hour still leaves room before the first run, so the planned times do not change.
- Pushing it to two hours before the anchor cannot work, because the runs and binding need five hours. The job is not rejected: it is re-planned with that start as the new lower bound, and an
anchor_infeasibleconflict records the shortfall (180 minutes here: "The plan cannot meet its anchor: it is short by 180 minutes (tasks cannot start early enough).").requiredStartbecomes the delayed start (tooLatein the code).
Troubleshooting
Every request below failed for real while writing this guide. Nothing was created in any of them.
async function expectError(label: string, input: CreateWorkflowGraphInput, status: number, code: string) {
try {
await client.workflowInstances.createWithGraph(input)
} catch (e) {
if (e instanceof ClockworkError && e.status === status && e.code === code) {
console.log(`${label}: ${e.status} ${e.code} - ${e.message}`)
return
}
throw e
}
throw new Error(`${label}: expected ${status} ${code}, but the request succeeded`)
}
const a = { ref: 'a', name: 'A', durationMinutes: 10 }
await expectError('unknown ref', { tasks: [a], dependencies: [{ from: 'a', to: 'ghost' }] }, 422, 'UNKNOWN_REF')
await expectError('duplicate ref', { tasks: [a, a] }, 422, 'DUPLICATE_REF')
await expectError('cycle', { tasks: [a, { ...a, ref: 'b' }], dependencies: [{ from: 'a', to: 'b' }, { from: 'b', to: 'a' }] }, 422, 'CYCLE_DETECTED')
await expectError('anchor required', { instance: { schedulingDirection: 'backward' }, tasks: [a] }, 422, 'ANCHOR_REQUIRED')
await expectError('over capacity',
{ resources: [{ ref: 'press', name: `Press ${suffix}`, type: 'asset', capacity: 2 }], tasks: [{ ...a, requirements: [{ resourceRef: 'press', units: 3 }] }] },
422, 'REQUIREMENT_EXCEEDS_CAPACITY')
await expectError('unknown field', { tasks: [{ ...a, colour: 'cyan' } as never] }, 400, 'VALIDATION_ERROR')| You see | Why | Fix |
|---|---|---|
422 UNKNOWN_REF "Dependencies reference unknown task refs: ghost" | A dependency's from or to is not a task ref in this request. | Fix the ref. The response's refs lists the offenders. The same code, "Tasks reference unknown resource refs: ...", means a resourceRefs or requirements[].resourceRef names a resource not in resources. |
422 DUPLICATE_REF "Duplicate task refs: a" | Two tasks (or two resources) share a ref. | Make refs unique. |
422 CYCLE_DETECTED "Dependency cycle: a -> b -> a" | The dependencies loop. | Remove one dependency on the cycle named in the message. |
422 ANCHOR_REQUIRED "schedulingDirection 'backward' requires instance.anchor" | Backward scheduling (or a task anchorWindow) needs an anchor. | Add instance.anchor, or use 'forward'. |
422 REQUIREMENT_EXCEEDS_CAPACITY "Task "a" requires 3 units of "press", which has capacity 2" | A task needs more units than the resource can ever provide. | Lower units, raise capacity, or split the task. |
400 VALIDATION_ERROR | An unknown field (every object is strict), a wrong type, or a limit exceeded (500 tasks, 2,000 dependencies, 100 resources). The SDK's message is just HTTP 400. | Read error.details.issues[]: each has a path and a message, for example "Unrecognized key(s) in object: 'colour'". |
404 NOT_FOUND "Not found" | The planning flag is off on this platform. | Use an environment where it is enabled. |
Several reference problems at once are all reported in details.errors.
Known limitations
- Pool assignment is opt-in. A pool requirement is a claim on the pool's total capacity unless you set
instance.assignmentPolicy(balance_loadorearliest_finish, withresourceLeveling: 'delay'); then the planner picks a member per requirement. Onlycapability:<id>pools have members, and membership changes take effect at the next recompute. See Assign from a pool. - Leveling is opt-in. The default
resourceLeveling: 'off'only reports over-allocation (resource_capacity_breach). Set'delay'to have it resolved. - Prefer
createWithGraphfor the whole job.workflowInstances.createstoresanchor,schedulingDirectionandresourceLevelingtoo when the platform flag is on, but it creates no tasks; with the flag off it drops them without an error. Check the instance you get back. - Infeasible plans return
201. Checkschedule.conflictson every response; the status code does not tell you the plan is achievable. - Resource edits do not re-plan the job by themselves. Changing a resource's capacity takes effect at the next task change in the job.
- Events over SSE are polling-backed.
GET /events/streamchecks for new events about once a second and ends itself before the platform's 300-second function limit, so keep a reconnect loop that sendsLast-Event-ID(see Webhooks and Event-Driven Patterns). - Flag. The endpoints in this guide need
CLOCKWORK_PLANNING_V2, which is on in production and preview as of 2026-10-12; it is a single global switch.
Related
- Workflow Instances:
createWithGraphreference — every field, the response and the error table - Batch Graphs — add tasks and dependencies to an existing job
- Anchored Scheduling — backward planning and anchor windows
- Resource Leveling — what
'delay'does and how contention is resolved - Resource Requirements — units and occupancy modes
- Schedule Simulation — preview changes without applying them
- Webhooks —
schedule.changed,task.delayed