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

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.

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

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

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

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

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

typescript
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 === 120

Without 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

typescript
const balanced = await runs('balance_load')
for (const a of balanced.data.schedule.assignments!) console.log(a.ref, '->', a.assignedResourceId)
text
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 -> Ana
assignmentPolicyA pool requirement goes to
(omitted, default)the pool as a whole: no member is chosen
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
  • Requires leveling. The choice is made while the leveler places tasks, so a policy needs resourceLeveling: 'delay'. Without it the request is 422 ASSIGNMENT_REQUIRES_LEVELING (assignmentPolicy requires resourceLeveling 'delay'). The policy is only available through createWithGraph; the plain workflowInstances.create does not honor it.
  • One member per requirement. All of a requirement's units go 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:

typescript
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

text
removed Bo from the pool: run-1 still shows Bo until the job is next recomputed
after a delay on another task: run-1 -> Cy

Taking 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 status or its capacity, or removing the capability does not re-plan any job. The stored assignedResourceId and the plan stay as they were until the job's next recompute.
  • Assignment changes emit no event. schedule.changed reports time changes only. After the removal above the log held task.delayed and schedule.changed, and nothing about assignment. To notice a reassignment, read listRequirements after 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_resource to 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.