Resource Requirements

A task can claim units of a resource and say when within the task it actually holds them. Capacity checks use occupied time, not task duration, so a person who is only needed at the start and end of a long task is free in between.

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.

Requirements need the platform flag CLOCKWORK_PLANNING_V2. This is separate from capability-based task requirements ("some resource that can do X"): a requirement here names a specific resource or pool, and can ask the planner to pick the member.

Shape

typescript
{
  resourceId: '…',        // or poolId: 'capability:<uuid>' — exactly one
  units: 2,               // default 1; must not exceed the resource's capacity
  occupancy: { mode: 'full' },   // default
}
occupancyHolds the resource
{ mode: 'full' }for the whole task
{ mode: 'bookends', startMinutes, endMinutes }only for the first startMinutes and the last endMinutes (merged if they overlap on a short task)
{ mode: 'none' }never; recorded for visibility only

A capability:{id} pool's capacity is the sum of the capacities of its active resources (a resource with no declared capacity counts as 1).

Examples

A press with two slots and an operator who is only needed to load and unload it. The run needs both slots; the operator is held for the first and last 10 minutes only, so proofing work can use them in between:

typescript
const minutes = (iso: string) => new Date(iso).getTime() / 60_000
 
// A press with 2 slots. The run needs both slots, but the operator is only needed to load (first 10
// minutes) and unload (last 10 minutes).
const { data } = await client.workflowInstances.createWithGraph({
  resources: [
    { ref: 'press', name: `Press ${suffix}`, type: 'asset', capacity: 2 },
    { ref: 'operator', name: `Operator ${suffix}`, type: 'human', capacity: 1 },
  ],
  tasks: [
    {
      ref: 'run', name: 'Print run', durationMinutes: 120, earliestStart: start,
      requirements: [
        { resourceRef: 'press', units: 2 },
        { resourceRef: 'operator', occupancy: { mode: 'bookends', startMinutes: 10, endMinutes: 10 } },
      ],
    },
    // 40 minutes of proofing, planned into the middle of the run: the operator is free there
    { ref: 'proof', name: 'Proof sheets', durationMinutes: 40, earliestStart: new Date((minutes(start) + 40) * 60_000).toISOString(),
      requirements: [{ resourceRef: 'operator' }] },
  ],
})
if (data.schedule.conflicts.length !== 0) throw new Error('expected no conflicts: ' + JSON.stringify(data.schedule.conflicts))
const operator = data.schedule.utilization.find((u) => u.resourceRef === 'operator')!
// 20 minutes of bookends + 40 minutes of proofing = 60 occupied unit-minutes; peak 1 on a capacity-1 operator

On an existing task, setRequirements replaces the whole set and recomputes ([] clears it):

typescript
console.log('operator occupied', operator.occupiedUnitMinutes, 'of', operator.availableUnitMinutes, 'unit-minutes')
 
// The same claims on an existing task, replacing its set
const { data: claims } = await client.tasks.setRequirements(data.refs.run!, [
  { resourceId: data.resourceRefs.press!, units: 2 },
])
if (claims.length !== 1) throw new Error('setRequirements replaces')

resourceRef is only valid in a batch-graph request; elsewhere use resourceId or poolId. The field-by-field reference (limits, validation, errors, bookends longer than the task) is on the Tasks page; capacity, units and occupancy together are explained on the Resources page. Runnable: docs-examples/resources-guide-requirements.mts.

Shared state and setup time

Some resources are shared only while in a compatible state: a drying rack can hold a 60 degree job and a 65 degree job together but not a 90 degree one. Declare the state on the resource, and give each claim the value it needs:

typescript
// ── Shared state: a drying rack that tolerates 10 degrees difference between overlapping jobs ───
// 60 and 65 degrees can share the rack; 90 cannot, and needs a 20 minute changeover.
function dryJobs(resourceLeveling: 'off' | 'delay') {
  return client.workflowInstances.createWithGraph({
    instance: { name: `Drying ${suffix} ${resourceLeveling}`, resourceLeveling },
    resources: [{ ref: 'rack', name: `Rack ${suffix} ${resourceLeveling}`, type: 'asset', capacity: 2,
                  stateKey: 'temperature_c', stateTolerance: 10, stateSetupMinutes: 20 }],
    tasks: [
      { ref: 'a', name: 'Dry A (60)', durationMinutes: 60, earliestStart: start, requirements: [{ resourceRef: 'rack', state: 60 }] },
      { ref: 'b', name: 'Dry B (90)', durationMinutes: 60, earliestStart: start, requirements: [{ resourceRef: 'rack', state: 90 }] },
      { ref: 'c', name: 'Dry C (65)', durationMinutes: 60, earliestStart: start, requirements: [{ resourceRef: 'rack', state: 65 }] },
    ],
  })
}
const reported = await dryJobs('off')
if (!reported.data.schedule.conflicts.some((c) => c.type === 'resource_capacity_breach' && c.description.includes('tolerance'))) {
  throw new Error('expected a state conflict: ' + JSON.stringify(reported.data.schedule.conflicts))
  • Claims may overlap on the resource only if their states differ by at most stateTolerance (A and C share the rack; B cannot overlap either, however much capacity is free).
  • Between claims whose states differ by more, the resource needs stateSetupMinutes to change over. In the run above, B starts 20 minutes after A and C finish.
  • A claim with no state is compatible with everything. stateKey is a label for humans; stateTolerance and stateSetupMinutes are what the planner uses. stateKey and stateTolerance go together, and stateSetupMinutes needs stateKey (400 otherwise). Only the occupied part of a claim counts (a bookends claim leaves its middle free).
  • state is valid on a single resource that declares a state, not on a pool, otherwise 422 STATE_NOT_SUPPORTED.
  • With leveling off, an incompatible overlap (or a missing changeover) is reported as a resource_capacity_breach conflict; with leveling delay the planner separates the claims. Recomputes are triggered by task mutations, not by editing a resource's state.

Set it on existing resources with resources.create / resources.update (stateKey, stateTolerance, stateSetupMinutes; null clears; an update that sets key and tolerance resets the setup time unless you send it too). See Resources.

What you get

  • Creation-time validation: units above a resource's capacity are rejected with 422 REQUIREMENT_EXCEEDS_CAPACITY (refs: the task and resource).
  • Conflicts: when planned times over-allocate a resource (occupied units > capacity at any instant), a resource_capacity_breach conflict is raised on the task that tipped it over. Other jobs sharing the same resource count toward usage.
  • No movement: this reports over-allocation but does not move tasks. Automatic resolution is resourceLeveling: 'delay' (Resource Leveling); without it, fix a conflict yourself as shown on the Conflicts page.

Assign from a pool

A requirement on poolId: 'capability:<id>' normally claims capacity on the pool as a whole: three operators means three slots, and nobody in particular does the work. With an assignmentPolicy the planner instead picks one member for each such requirement and tells you who. A pool is every active resource that holds the capability, managed with the capability endpoints you already use (resources.assignCapability, removeCapability, a resource's status); there is no separate pool API.

typescript
const { data: capability } = await client.capabilities.create({ organizationId, name: `Press operator ${suffix}` })
// ... three operators created and given the capability with client.resources.assignCapability(...)
const poolId = `capability:${capability.id}`
 
const result = await client.workflowInstances.createWithGraph({
  instance: { name: 'Print batch', resourceLeveling: 'delay', assignmentPolicy: 'balance_load' },
  tasks: Array.from({ length: 6 }, (_, i) => ({
    ref: `run-${i + 1}`, name: `Print run ${i + 1}`, durationMinutes: 60, earliestStart: start,
    requirements: [{ poolId }],
  })),
})
 
result.data.schedule.assignments
// [{ ref: 'run-1', taskId, requirementId, poolId, assignedResourceId }, ...] two runs per operator

Runnable: docs-examples/pool-assignment.mts.

assignmentPolicyA pool requirement goes to
(omitted, default)the pool as a whole, exactly as before: no member is chosen and nothing changes
balance_loadthe member with the least work reserved so far, relative to its capacity; ties go to the member that can start earliest, then to the lowest resource id
earliest_finishthe member the task can finish on first; ties go to the least-loaded member, then to the lowest resource id

What you get back and how it behaves:

  • Where it shows. schedule.assignments in the createWithGraph (and graph-form simulation) response, assignedResourceId on each requirement from tasks.listRequirements, and assignmentPolicy on the job. All of them are absent when no policy is set.
  • Needs leveling. The choice is made inside the leveler, so assignmentPolicy requires resourceLeveling: 'delay'; without it the request is a 422 ASSIGNMENT_REQUIRES_LEVELING. It is only available through createWithGraph.
  • Per requirement, one member. The whole requirement (all its units, with its occupancy) goes to a single member, so only members whose capacity (1 if unset) covers the units qualify. If no member does, that requirement stays on the pool as a whole and has no assignment.
  • Deterministic. The same input gives the same members and times, and recomputing without changes changes nothing.
  • Sticky. A task that has started, finished, was cancelled or is fixed keeps its member for good, even if that member later leaves the pool (it keeps holding that member's capacity). A task that has not started keeps its member on every recompute while that member is still in the pool and large enough. A member that joins later receives only tasks that have no assignment yet; it does not take work from others. Replacing a task's requirements with setRequirements keeps the member for a requirement that stays on the same pool.
  • When an assignment changes. Only for a task that has not started, and only when its member was removed from the pool (resource retired or made unavailable, or capability removed) or is too small for the requirement. It then moves to the member the policy picks; nothing else moves.
  • Direct claims count. A task that names a member directly (resourceId) occupies that member for assignment purposes, so a pool task is steered around it.
  • Conflicts and utilization are reported per member, not per pool, for requirements that were assigned.

Known limitations

  • Without an assignmentPolicy a requirement names a specific resource or claims a pool's total capacity; nobody is chosen.
  • Pool membership is read when the job is recomputed. With planning v2 on, retiring a member, making it unavailable, changing its capacity, or adding or removing it from a capability replans the planner-managed jobs that draw on the pool, in the background (at most 25 jobs per change, a few seconds of lag; see Resources). Until that finishes the stored assignments can still show the old member. A job without a planner (no leveling, no backward anchor) recomputes only on its next task change.
  • Pool assignment only works for capability:<id> pools. Any other pool id is opaque and has no members.
  • A requirement with occupancy: { mode: 'none' } holds nothing and is not assigned.
  • A task started before a policy took effect, or before its requirement was assigned, has no member and keeps holding the pool as a whole.
  • Assignment changes are not announced as events; schedule.changed reports time changes only.
  • A what-if simulation (baseInstanceId) applies add_resource / remove_resource to capacities only; it does not add or remove pool members.
  • Units are checked against capacity per claim at creation. Two claims on the same resource in one task are not summed.
  • A requirements change recomputes only the job you edited; a clash stored on another job that shares the resource is refreshed when that job recomputes.
  • tasks.assignResource and resources.allocate are not requirements: they do not give units or occupancy and the planner does not use them for this capacity check.