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

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

typescript
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)?.peakUnits

What each piece does:

  • anchor is 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 a 422 ANCHOR_REQUIRED).
  • requirements are what make a task claim capacity. units: 2 on a press with capacity: 2 takes both slots. occupancy: { mode: 'bookends', ... } holds the operator only for the first and last minutes. (resourceRefs would 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, set assignmentPolicy (see Assign from a pool); otherwise a pool is only a total capacity.
  • Asking for more units than the resource has (units: 3 on the press) is rejected up front with 422 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:

typescript
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

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

text
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 300

4. Read the schedule

data.schedule holds everything; data.refs and data.resourceRefs map your refs to the ids the platform generated.

ReadWhat it tells you
schedule.tasks[]Per task: plannedStart / plannedEnd (where it was placed), latestStart, slackSec, isCritical.
schedule.requiredStartWhen 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.makespanMinutesMinutes from requiredStart to the last planned end (here 300: 30 + 90 + 120 + 60).
schedule.finishThe last planned end. Equal to the anchor here.
schedule.criticalPathTask 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).

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

typeMeaning
anchor_infeasibleThe 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_breachA resource is over-allocated. Seen with resourceLeveling: 'off'; 'delay' is what resolves it.
deadline_violatedA task cannot finish by its deadline.
constraint_violatedA task falls outside an anchorWindow or other constraint window.
max_lag_exceededA 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).

typescript
// 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_infeasible conflict records the shortfall (180 minutes here: "The plan cannot meet its anchor: it is short by 180 minutes (tasks cannot start early enough)."). requiredStart becomes the delayed start (tooLate in the code).

Troubleshooting

Every request below failed for real while writing this guide. Nothing was created in any of them.

typescript
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 seeWhyFix
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_ERRORAn 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_load or earliest_finish, with resourceLeveling: 'delay'); then the planner picks a member per requirement. Only capability:<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 createWithGraph for the whole job. workflowInstances.create stores anchor, schedulingDirection and resourceLeveling too 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. Check schedule.conflicts on 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/stream checks for new events about once a second and ends itself before the platform's 300-second function limit, so keep a reconnect loop that sends Last-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.