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
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.
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
{
resourceId: '…', // or poolId: 'capability:<uuid>' — exactly one
units: 2, // default 1; must not exceed the resource's capacity
occupancy: { mode: 'full' }, // default
}occupancy | Holds 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:
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 operatorOn an existing task, setRequirements replaces the whole set and recomputes ([] clears it):
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:
// ── 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
stateSetupMinutesto change over. In the run above, B starts 20 minutes after A and C finish. - A claim with no
stateis compatible with everything.stateKeyis a label for humans;stateToleranceandstateSetupMinutesare what the planner uses.stateKeyandstateTolerancego together, andstateSetupMinutesneedsstateKey(400otherwise). Only the occupied part of a claim counts (abookendsclaim leaves its middle free). stateis valid on a single resource that declares a state, not on a pool, otherwise422 STATE_NOT_SUPPORTED.- With leveling off, an incompatible overlap (or a missing changeover) is reported as a
resource_capacity_breachconflict; with levelingdelaythe 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_breachconflict 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.
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 operatorRunnable: docs-examples/pool-assignment.mts.
assignmentPolicy | A pool requirement goes to |
|---|---|
| (omitted, default) | the pool as a whole, exactly as before: no member is chosen and nothing changes |
balance_load | the 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_finish | the 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.assignmentsin thecreateWithGraph(and graph-form simulation) response,assignedResourceIdon each requirement fromtasks.listRequirements, andassignmentPolicyon the job. All of them are absent when no policy is set. - Needs leveling. The choice is made inside the leveler, so
assignmentPolicyrequiresresourceLeveling: 'delay'; without it the request is a422 ASSIGNMENT_REQUIRES_LEVELING. It is only available throughcreateWithGraph. - Per requirement, one member. The whole requirement (all its
units, with its occupancy) goes to a single member, so only members whosecapacity(1 if unset) covers theunitsqualify. 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
setRequirementskeeps 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
assignmentPolicya 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.changedreports time changes only. - A what-if simulation (
baseInstanceId) appliesadd_resource/remove_resourceto 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.assignResourceandresources.allocateare not requirements: they do not give units or occupancy and the planner does not use them for this capacity check.