Capability Pools
Some work needs "a press operator", not "Ana". A capability pool is every active resource that holds one capability. A task requirement names the pool (poolId: 'capability:<capabilityId>') instead of a person, and the planner either treats the pool as one resource with the combined capacity of its members, or, with an assignmentPolicy, picks one member for each requirement and tells you who.
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.
There is no separate pool API. A pool is made of three things you already have: a capability, resources that hold it, and requirements that name it. This guide shows them in order with a print shop: three press operators and six print runs. The whole script is docs-examples/capability-pools.mts and every output below is from running it.
1. Define the capability
A capability is an organization-scoped label for something a resource can do.
const { data: me } = await client.me.get()
const organizationId = me.organizations[0]!.id
const { data: capability } = await client.capabilities.create({
organizationId,
name: 'Press operator',
description: 'Can run the offset press',
})Names are unique within an organization (409 CONFLICT for a second "Press operator"). capabilities.list({ organizationId, search? }), get, update, delete and merge(sourceId, targetId) (re-points every link of one capability at another, then deletes it) complete the set. The scopes are capabilities:read and capabilities:write.
2. Add and remove members
A resource joins the pool by holding the capability. assignCapability is idempotent; removeCapability takes it away (404 NOT_FOUND if the resource did not hold it).
const { data: operatorType } = await client.resourceTypes.create({ organizationId, name: 'Operator', schedulable: true })
for (const name of ['Ana', 'Bo', 'Cy']) {
const { data: operator } = await client.resources.create({ organizationId, name, resourceTypeId: operatorType.id, capacity: 1 })
await client.resources.assignCapability(operator.id, capability.id) // joins the pool
}
await client.resources.listCapabilities(operator.id) // which pools is this resource in?
await client.resources.removeCapability(operator.id, capability.id) // leaves the poolA resource and a capability must belong to the same organization (400 VALIDATION_ERROR otherwise). These calls need resources:write. Only active resources count: setting a resource's status to 'unavailable' or 'retired' takes it out of every pool it is in, and 'active' brings it back.
3. How big is the pool?
A pool's capacity is the sum of its active members' capacities, where a member with no capacity counts as 1. The pool of Ana, Bo and Cy (capacity 1 each) has capacity 3. The planner uses it, and it is checked when you create a requirement:
pool of 3 x capacity 1: 4 units -> 422 REQUIREMENT_EXCEEDS_CAPACITY | 3 units -> 201
a member with no capacity counted as 1; once retired it left the pool againAdding a fourth operator with no capacity made 4 units acceptable; retiring that operator made it a 422 again. A pool with no active members has capacity 0.
4. Require "an operator"
A requirement whose poolId is capability:<id> claims units of the pool. It works in createWithGraph (task requirements), in tasks.createBatch and in tasks.setRequirements, with the same units and occupancy as any other requirement (Resource Requirements).
const poolId = `capability:${capability.id}`
const runs = (assignmentPolicy?: 'balance_load' | 'earliest_finish') =>
client.workflowInstances.createWithGraph({
instance: { name: 'Print batch', resourceLeveling: 'delay', ...(assignmentPolicy ? { assignmentPolicy } : {}) },
tasks: Array.from({ length: 6 }, (_, i) => ({
ref: `run-${i + 1}`, name: `Print run ${i + 1}`, durationMinutes: 60, earliestStart: start,
requirements: [{ poolId }],
})),
})
const plain = await runs() // no policy
// plain.data.schedule.assignments === undefined, makespanMinutes === 120Without an assignmentPolicy nobody in particular is chosen: six one-hour runs on a pool of three finish in two hours (makespanMinutes: 120), and the response has no assignments. Any other poolId (not starting with capability:) is an opaque name with no members and no capacity limit.
5. Let the planner choose: assignment policies
const balanced = await runs('balance_load')
for (const a of balanced.data.schedule.assignments!) console.log(a.ref, '->', a.assignedResourceId)balance_load, runs per operator: {"Ana":2,"Bo":2,"Cy":2}
run-1 -> Bo
run-2 -> Cy
run-3 -> Ana
run-4 -> Bo
run-5 -> Cy
run-6 -> AnaassignmentPolicy | A pool requirement goes to |
|---|---|
| (omitted, default) | the pool as a whole: no member is chosen |
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 |
- Requires leveling. The choice is made while the leveler places tasks, so a policy needs
resourceLeveling: 'delay'. Without it the request is422 ASSIGNMENT_REQUIRES_LEVELING(assignmentPolicy requires resourceLeveling 'delay'). The policy is only available throughcreateWithGraph; the plainworkflowInstances.createdoes not honor it. - One member per requirement. All of a requirement's
unitsgo to one member, so only members whose capacity covers the units qualify. If none does, the requirement stays on the pool as a whole and has no assignment. - Deterministic. The same input gives the same members and times.
The full rules, including what happens to tasks that have started, are in Resource Requirements: assign from a pool.
6. Where the assignment is stored
The choice is persisted on the requirement as assignedResourceId, so you can read it later without re-running the plan:
const { data: requirements } = await client.tasks.listRequirements(first.taskId)
requirements[0]
// { id, taskId, resourceId: null, poolId: 'capability:…', units: 1, occupancy: { mode: 'full' }, assignedResourceId: '…' }assignedResourceId is absent when no policy is set or no member could take the requirement. It also appears in schedule.assignments of the createWithGraph response ({ ref, taskId, requirementId, poolId, assignedResourceId }). Conflicts and utilization for an assigned requirement are reported against the member, not the pool.
Sticky. A task that has started, finished, was cancelled or is fixed keeps its member for good. 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 only receives tasks that have no assignment. Replacing a task's requirements with setRequirements keeps the member for a requirement that stays on the same pool.
Changing the pool later
removed Bo from the pool: run-1 still shows Bo until the job is next recomputed
after a delay on another task: run-1 -> CyTaking Bo out of the pool changed nothing by itself. The next change to any task in the job (a delay, a completion, a requirements change) recomputed it, and run-1, which had not started, moved to another member. Nothing else moved.
Known limitations
- Membership changes do not recompute. Adding or removing a member, changing its
statusor itscapacity, or removing the capability does not re-plan any job. The storedassignedResourceIdand the plan stay as they were until the job's next recompute. - Assignment changes emit no event.
schedule.changedreports time changes only. After the removal above the log heldtask.delayedandschedule.changed, and nothing about assignment. To notice a reassignment, readlistRequirementsafter the events you do get. occupancy: { mode: 'none' }is not assigned. Such a requirement holds nothing; in the example the "none" requirement got no member and the "full" one did.- Backward plans choose members in the forward pass. With
schedulingDirection: 'backward'the latest-feasible pass treats the pool as one aggregate resource, then members are chosen in the forward pass that places the tasks. A backward job with a policy still gets assignments (4 of 4 in the example). - A started task that has no member (it started before the policy or before the assignment) keeps holding the pool as a whole.
- A what-if simulation applies
add_resource/remove_resourceto capacities only, not to pool members. - Units are checked against capacity per claim at creation; two claims on the same pool in one task are not summed.
Related
- Resource Requirements — units, occupancy, shared state and the assignment rules in full
- Resource Leveling — what
resourceLeveling: 'delay'does - Resources —
capacity,status, availability windows - Tasks —
setRequirements,listRequirements