Tasks
A Task is the atomic unit of work in Clockwork. Tasks belong to a WorkflowInstance, have a duration and optional deadline, and flow through a lifecycle: pending → ready → running → completed (or blocked, failed, cancelled).
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.
The scheduling engine automatically re-computes start times, critical path, and conflicts after every task mutation.
When to use client.tasks
- Creating tasks dynamically as your system processes new work items
- Building a human task inbox that polls for
readytasks assigned to a user - Completing or delaying tasks from a webhook handler or automation script
- Monitoring task slack (
slackSec) to identify scheduling risk
Methods
| Method | Description |
|---|---|
create(input) | Add a task to a workflow instance |
get(taskId) | Fetch a task with computed schedule fields |
list(opts?) | List tasks filtered by instance or status |
update(taskId, input) | Update name, duration, status, dates, tags, priority |
delete(taskId) | Delete task; downstream tasks reschedule automatically |
complete(taskId) | Mark done; returns newly unlocked downstream tasks |
assign(taskId, userId) | Assign to a platform user |
assignResource(taskId, resourceId) | Assign a resource (equipment, agent, service) |
delay(taskId, input) | Delay by minutes or to a new earliest start; slip propagates downstream |
createBatch(input) | Create up to 500 tasks in one atomic request |
setRequirements(taskId, requirements) | Replace the task's units-and-occupancy claims on resources |
listRequirements(taskId) | Read the task's claims |
createBatch, setRequirements and listRequirements answer 404 while the platform flag CLOCKWORK_PLANNING_V2 is off. tasks.simulate(input) (a dry run for one proposed task, POST /tasks/simulate) also exists; whole-job what-ifs are in Schedule Simulation.
tasks.create(input)
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
name | string | yes | Human-readable task name |
workflowInstanceId | string | no | Bind to a running workflow instance |
durationMinutes | integer >= 0 | no | Estimated duration in minutes, default 0 |
durationSeconds | integer >= 0 | no | Fine-grained duration override |
earliestStart | string | no | ISO 8601 (with Z or an offset); lower bound on the start, defaults to now |
deadline | string | no | ISO 8601; a task that cannot finish by it gets a deadline_violated conflict |
isFixed | boolean | no | If true, start time never moves regardless of dependencies |
priority | 'high' | 'medium' | 'low' | no | Display priority (does not affect scheduling) |
assigneePlatformUserId | string | no | Assign on creation |
tags | string[] | no | Freeform labels |
bufferAfterMinutes | number | no | Cooldown time after the task finishes |
subjectType | string | no | Opaque label for external entity type (e.g., 'customer') |
subjectId | string | no | External entity ID |
Returns
Promise<ApiResponse<Task>> — data is the created Task (201).
Errors
| Status | code | Cause |
|---|---|---|
400 | VALIDATION_ERROR | Body fails the schema (empty name, a non-ISO date, a negative duration, a non-UUID id, a priority other than high/medium/low) |
400 | NOT_ASSIGNABLE | The actorId you gave cannot be resolved to an assignable person |
403 | SLOT_NOT_PERMITTED | slotId is set and the assignee has not granted that slot to you |
401 / 403 | UNAUTHORIZED / FORBIDDEN | Missing or invalid credential; a key without tasks:write |
Example
import { Clockwork } from '@clockwork/sdk'
const client = new Clockwork({ apiKey: process.env.CLOCKWORK_API_KEY })
const { data: task } = await client.tasks.create({
name: 'Review contract draft',
workflowInstanceId: 'wi_abc123',
durationMinutes: 30,
priority: 'high',
deadline: '2026-07-01T17:00:00Z',
tags: ['legal', 'q3'],
})
console.log(task.id, task.status) // tsk_... 'pending'tasks.createBatch(input)
Creates many tasks in one request: one transaction and one schedule computation for the batch. It counts as one request toward rate limits. To create tasks and dependencies and resources together, use Batch Graphs (workflowInstances.createWithGraph).
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
tasks | array | yes | 1 to 500 tasks |
workflowInstanceId | string | no | Put every task in this instance (404 NOT_FOUND if it does not exist) |
projectId | string | no | Defaults to your project; must match the instance's project |
Each entry takes the fields of a batch-graph task (name is required; durationMinutes, durationSeconds, earliestStart, deadline, isFixed, bufferAfterMinutes, assigneePlatformUserId, tags, priority, subjectType, subjectId, metadata, description, requirements), with these differences:
refis optional. It is only a key for therefsmap in the response.resourceRefsis not accepted (there are no same-request resources here), and a requirement may not useresourceRef: give it aresourceIdorpoolId.- The body is strict: an unknown field is a
400 VALIDATION_ERROR, never silently dropped.
Returns
Promise<ApiResponse<{ tasks, refs, schedule }>>
| Field | Description |
|---|---|
tasks | The created task rows, in the order you sent them |
refs | ref to task id, for the tasks that had a ref |
schedule | { requiredStart, makespanMinutes } summary of the batch |
Ids are handed out in ascending order of the tasks array, so equal planner ties always break in the order you listed the tasks. The response does not contain conflicts. If a requirement over-allocates a resource, the task is created and the conflict is stored: read it with conflicts.list() (see Conflicts).
Atomicity and errors
The batch is all-or-nothing. Everything is validated before anything is written, and a rejected batch creates no tasks.
| Status | code | Cause |
|---|---|---|
400 | VALIDATION_ERROR | Empty batch, more than 500 tasks, unknown field, bad requirement shape |
404 | NOT_FOUND | Unknown workflowInstanceId |
422 | DUPLICATE_REF | The same ref twice (refs lists them) |
422 | REQUIREMENT_EXCEEDS_CAPACITY | A claim needs more units than the resource has |
422 | UNKNOWN_REF | A requirement names a resource that does not exist |
422 | STATE_NOT_SUPPORTED | A requirement gives state for a resource that declares none |
// A press with 2 slots. Both press runs need both slots.
const { data: press } = await client.resources.create({ name: `Press ${suffix}`, type: 'asset', capacity: 2 })
// ── 1. Create several tasks in one request ──────────────────────────────────────
const start = new Date(Date.now() + 60 * 60_000).toISOString()
const { data } = await client.tasks.createBatch({
tasks: [
{ ref: 'plates', name: `Make plates ${suffix}`, durationMinutes: 30, earliestStart: start },
{ ref: 'run', name: `Print run ${suffix}`, durationMinutes: 90, requirements: [{ resourceId: press.id, units: 2 }] },
{ name: `Pack boxes ${suffix}`, durationMinutes: 20 }, // no ref: not in `refs`
],
})
// ids are handed out in ascending order of the tasks array, and `refs` maps ref -> id
if (data.tasks.length !== 3) throw new Error('expected 3 tasks')
const ids = data.tasks.map((t) => t.id)
if (JSON.stringify(ids) !== JSON.stringify([...ids].sort())) throw new Error('ids are not ascending in input order')
if (data.refs.plates !== ids[0] || data.refs.run !== ids[1]) throw new Error('refs do not match input order')
if (Object.keys(data.refs).length !== 2) throw new Error('only tasks with a ref are in refs')Runnable: docs-examples/tasks-batch.mts. Batch tasks are planned together as their own group: they are not linked to existing tasks unless they share an assignee (those are re-planned together with the assignee's other work). Add dependencies afterwards with the Dependencies API.
Resource requirements: tasks.setRequirements / tasks.listRequirements
A requirement is a claim on a resource: how many units of it the task holds, and when within the task it holds them. The planner checks capacity against the occupied time of the claims, so a resource a task only touches at the start and end is free in between. This is a different thing from capability requirements on templates ("some resource that can do X") and from assignResource (compared below). The Resource Requirements guide explains the model; this is the reference.
setRequirements(taskId, requirements) replaces the task's whole set (it does not merge), then recomputes. Send [] to remove every claim. listRequirements(taskId) returns the stored claims. The replace is atomic: a rejected call leaves the previous set untouched. Rows get new ids on every setRequirements call.
Requirement fields
| Field | Type | Default | Description |
|---|---|---|---|
resourceId | string (uuid) | A specific resource. Exactly one of resourceId / poolId | |
poolId | string | A pool: capability:<capabilityId> (capacity = sum of its active resources, a resource with no capacity counts as 1) or any other string, which is opaque and unconstrained. With an assignmentPolicy on the job, a capability: pool requirement is resolved to one member (Assign from a pool) | |
units | integer > 0 | 1 | How many units are held. 0, negatives and fractions are a 400 |
occupancy | object | { mode: 'full' } | When within the task the units are held (below) |
state | number | none | The shared-state value the claim needs. Only valid on a single resource that declares stateKey / stateTolerance; see Resources |
Limits: at most 50 requirements per task (400 above that). The body is strict, so unknown fields are a 400. Inside a batch graph a requirement may use resourceRef instead of resourceId; setRequirements may not.
Occupancy modes
occupancy | Fields | Holds the resource |
|---|---|---|
{ mode: 'full' } | none | for the whole task |
{ mode: 'bookends', startMinutes, endMinutes } | both required, integers >= 0 | for the first startMinutes and the last endMinutes of the task only |
{ mode: 'none' } | none | never. The claim is stored and returned, but takes no capacity |
Bookends are measured from the task's start and end and are not checked against the duration: startMinutes + endMinutes may exceed it. The two windows are clamped to the task and merged where they overlap, so a 60-minute task with 45 + 45 holds the resource for the whole 60 minutes, once (not twice). 0 + 0 holds nothing. Extra keys inside occupancy are a 400.
Validation
| Status | code | Cause |
|---|---|---|
400 | VALIDATION_ERROR | Bad shape (both or neither of resourceId/poolId, units not a positive integer, a bad occupancy, more than 50 requirements, unknown field) |
404 | NOT_FOUND | Unknown task |
422 | UNKNOWN_REF | resourceId does not exist |
422 | REQUIREMENT_EXCEEDS_CAPACITY | One claim asks for more units than the resource (or pool) can ever provide. A capability: pool with no resources has capacity 0. refs is [taskName, resourceId], and units and capacity are included |
422 | STATE_NOT_SUPPORTED | state on a pool or on a resource that declares no stateKey |
Capacity is checked per claim, not summed. Two claims of 2 units on a capacity-2 resource in the same task are accepted; the planner then reports the overload as a conflict. A resource with no capacity is unconstrained: any units is accepted and never conflicts.
Example
A press with two slots and one operator. The press run needs both slots; the operator is only needed to load and unload.
// 60-minute press run holding both slots. The operator is needed for the first and last 10 minutes.
// Two helper tasks need the operator the whole time they run.
const { data: batch } = await client.tasks.createBatch({
tasks: [
{ ref: 'run', name: `Press run ${suffix}`, durationMinutes: 60, earliestStart: at(0),
requirements: [
{ resourceId: press.id, units: 2 },
{ resourceId: operator.id, occupancy: { mode: 'bookends', startMinutes: 10, endMinutes: 10 } },
] },
// minutes 20-40: inside the free middle of the run, so no clash
{ ref: 'proof', name: `Proof sheet ${suffix}`, durationMinutes: 20, earliestStart: at(20),
requirements: [{ resourceId: operator.id }] },
// minutes 5-20: overlaps the operator's first 10 minutes of the run
{ ref: 'early', name: `Early check ${suffix}`, durationMinutes: 15, earliestStart: at(5),
requirements: [{ resourceId: operator.id }] },
],
})
const ids = batch.refs as Record<string, string>Replace the set, clear it, and read it back:
// ── setRequirements REPLACES the whole set ───────────────────────────────────────────
// Make the operator "none" on the run (recorded, but never holds the operator), keep the press.
const { data: replaced } = await client.tasks.setRequirements(ids.run!, [
{ resourceId: press.id, units: 2 },
{ resourceId: operator.id, occupancy: { mode: 'none' } },
])
// An empty array removes every claim
const { data: cleared } = await client.tasks.setRequirements(ids.run!, [])
if (cleared.length !== 0 || (await client.tasks.listRequirements(ids.run!)).data.length !== 0) throw new Error('not cleared')Runnable: docs-examples/tasks-requirements.mts (it also exercises every validation error above).
What happens to the schedule
Requirements do not move tasks by themselves. When two claims on a resource overlap in time and together exceed its capacity, the plan keeps them where they are and stores a resource_capacity_breach conflict on the claim that started last. To have the planner delay tasks until the resource is free, create the job with resourceLeveling: 'delay' (Resource Leveling). Other jobs that claim the same resource count toward usage.
setRequirements vs assignResource vs resources.allocate
setRequirements | assignResource | resources.allocate | |
|---|---|---|---|
| Records | units + time within the task | the resource id in task.resourceIds | an allocation row (allocated / released) |
| Checks capacity | yes: units against capacity at creation, overlap on the plan | no. Accepts duplicates and ids that do not exist | yes: 409 CAPACITY_EXCEEDED if the resource is full while the task's window overlaps other allocations |
| Understands units and bookends | yes | no | no (one allocation = one slot, for the task's whole window) |
| Recomputes the plan | yes | no | no |
| Reports overload as a conflict | yes (resource_capacity_breach) | no | only through the older whole-task capacity pass, and not for tasks that have requirements |
Use setRequirements (or requirements when creating) for anything time-based or multi-unit: it is the only one the planner and leveling use for capacity. Use assignResource as a plain label ("this task uses this press") and resources.allocate when you want a capacity-checked booking of a whole task window. An allocation is not a requirement: allocating a resource to a task does not stop a requirement claim from using the same capacity. See Resources.
Known limitations
- A requirements change recomputes only the job you edited. A clash that was stored against a task in another job sharing the resource is not cleared (or created) until that job recomputes, for example when you re-save its requirements.
docs-examples/tasks-requirements.mtsdoes this. createBatchreturns no conflicts (above).- Pool assignment ("any of these helpers") needs an
assignmentPolicyon the job (set throughcreateWithGraph). Without one acapability:pool is only a total capacity.setRequirementskeeps the assigned member for a requirement that stays on the same pool; membership changes take effect at the next recompute.
tasks.get(taskId)
Fetches a single task including engine-computed fields (slackSec, isCritical).
const { data: task } = await client.tasks.get('tsk_abc123')
console.log(task.slackSec) // seconds before the critical-path deadline
console.log(task.isCritical) // true if on the critical pathtasks.list(opts?)
Options
| Option | Type | Description |
|---|---|---|
workflowInstanceId | string | Filter to one instance |
status | TaskStatus | Filter by status |
const { data: tasks } = await client.tasks.list({
workflowInstanceId: 'wi_abc123',
status: 'ready',
})The list returns the tasks you can see (as assignee, creator or owner; every task of the project for a project-bound key), ordered by deadline (tasks without one last), then earliestStart, then newest first. Cancelled tasks are never listed, not even with status: 'cancelled'. A status value that is not one of the seven statuses is ignored rather than rejected. A guest token sees only the tasks in its scope.
tasks.update(taskId, input)
Updates any mutable task field. All fields are optional.
await client.tasks.update('tsk_abc123', {
name: 'Review final contract',
durationMinutes: 45,
deadline: '2026-07-02T12:00:00Z',
priority: 'high',
})status can be set here (pending, ready, running, blocked, cancelled, completed, failed). deadline: null and priority: null clear them. Reassigning goes through tasks.assign. In a job scheduled by the planner (schedulingDirection: 'backward' or resourceLeveling: 'delay'), moving a task to running pins it where it started, so later recomputes never move it. Errors: 400 VALIDATION_ERROR, 404 NOT_FOUND, 403 FORBIDDEN (a task you cannot see).
tasks.complete(taskId)
Marks a task as completed. Downstream tasks that were waiting on this one become ready and are returned in unlockedTasks.
const { data } = await client.tasks.complete('tsk_abc123')
console.log(data.task.status) // 'completed'
console.log(data.unlockedTasks) // [{ id: '...', name: 'Send confirmation email' }]| Status | code | Cause |
|---|---|---|
404 | NOT_FOUND | Unknown task |
403 | FORBIDDEN | A task you cannot see (a guest token is checked against its scope instead) |
400 | ALREADY_DONE | The task is already completed |
409 | REQUIRED_ARTIFACTS_MISSING | The task has required artifacts without an approved approval; details lists them |
Completing a task also emits task.completed and, for each successor that becomes ready, task.ready.
tasks.assign(taskId, userId)
Assigns a task to a platform user (user must exist in your Clockwork account).
await client.tasks.assign('tsk_abc123', 'user_xyz')tasks.assignResource(taskId, resourceId)
Adds a resource id to the task's resourceIds. It is a label: it does not check that the resource exists or has capacity, accepts the same id twice, does not recompute the schedule, and does not create conflicts. For capacity use setRequirements or resources.allocate (compare them above). See Resources.
await client.tasks.assignResource('tsk_abc123', 'res_xyz')tasks.delay(taskId, input)
Delays a task. The slip propagates automatically to all downstream tasks.
Parameters
| Parameter | Type | Description |
|---|---|---|
delayMinutes | integer > 0 | Push the task's earliestStart back by this many minutes |
newEarliestStart | string | ISO 8601 — set an absolute new earliestStart (it wins if both are sent) |
reason | string | Optional note, stored on the task (metadata.delayReason) and carried by the task.delayed event |
Send one of delayMinutes / newEarliestStart: neither is 400 VALIDATION_ERROR (Provide delayMinutes or newEarliestStart), and so is a non-positive or fractional delayMinutes. The delay moves the task's lower bound: it recomputes the job, emits task.delayed and, if other tasks moved, one schedule.changed. 404 NOT_FOUND for an unknown task, 403 FORBIDDEN for one you cannot see. A guest token can only delay, never move a task earlier (422).
await client.tasks.delay('tsk_abc123', {
delayMinutes: 60,
reason: 'Waiting on legal review',
})tasks.delete(taskId)
Permanently deletes a task. Dependent tasks are disconnected and rescheduled.
await client.tasks.delete('tsk_abc123')Schedule fields on a task
| Field | Meaning |
|---|---|
earliestStart | Lower bound on when the task may start. You set it; a delay raises it. On a forward job with leveling off (the default) the engine also writes the live scheduled start here after every recompute, so it moves when upstream tasks slip. On a backward or leveled job it stays your lower bound and is never overwritten by planning |
plannedStart / plannedEnd | Where the last whole-job plan placed the task. null for a plain tasks.create |
latestStart | The latest start that still lets the job meet its deadline or anchor |
slackSec | Engine-computed float, in seconds (latestStart - earliestStart on an anchored job); stored as a 32-bit integer, so it is capped at about 68 years |
isCritical | Whether the task is on the critical path |
Which of these stay current. plannedStart, plannedEnd, latestStart and the job's requiredStart / makespanMinutes are rewritten on every recompute only for jobs the planner manages (schedulingDirection: 'backward' or resourceLeveling: 'delay'). On a forward job with leveling off they keep the values from creation after a delay (checked: delaying an upstream task by 120 minutes moved earliestStart 120 minutes and left plannedStart untouched). Read earliestStart there.
Anchor windows. A task in createWithGraph may carry anchorWindow: { minOffsetMinutes?, maxOffsetMinutes?, applyTo?: 'finish' | 'start' } (minutes from the job's anchor, negative = before). It is not a field of Task: it creates an anchor_window constraint attached to the task. Requirements are likewise not part of Task; read them with tasks.listRequirements.
Task type reference
type TaskStatus = 'pending' | 'ready' | 'running' | 'blocked' | 'cancelled' | 'completed' | 'failed'
type TaskPriority = 'high' | 'medium' | 'low'
interface Task {
id: string
name: string
description: string | null
status: TaskStatus
priority: TaskPriority | null
earliestStart: string // ISO 8601. Lower bound on the start; on a forward job with leveling off it is also the live scheduled start
deadline: string | null
durationMinutes: number
durationSeconds: number | null
bufferAfterMinutes: number
bufferAfterSeconds: number | null
isFixed: boolean
slackSec: number | null // Engine-computed: float before deadline breach
isCritical: boolean | null // Engine-computed: on the critical path?
plannedStart: string | null // Where the last whole-job plan placed the task. null for a plain tasks.create; only kept current on planner-managed jobs
plannedEnd: string | null // plannedStart + duration
latestStart: string | null // Latest start that still meets the deadline / anchor
workflowInstanceId: string | null
assigneePlatformUserId: string | null
resourceIds: string[] // ids added with assignResource (a label; not a capacity claim)
tags: string[]
subjectType: string | null
subjectId: string | null
createdAt: string
updatedAt: string
}Resource claims are not part of Task: read them with tasks.listRequirements(taskId):
type RequirementOccupancy =
| { mode: 'full' }
| { mode: 'bookends'; startMinutes: number; endMinutes: number }
| { mode: 'none' }
interface RequirementInput {
resourceId?: string
poolId?: string
units?: number // default 1
occupancy?: RequirementOccupancy // default { mode: 'full' }
state?: number
}
interface TaskResourceRequirement {
id: string
taskId: string
resourceId: string | null
poolId: string | null
units: number
occupancy: RequirementOccupancy
state?: number | null
assignedResourceId?: string // the pool member chosen by an assignmentPolicy; absent otherwise
}The task rows returned by the API carry a few more fields than Task declares (for example metadata).
Related
- Workflow Instances — tasks belong to instances
- Dependencies — model finish-to-start ordering between tasks
- Approvals — require human sign-off before a task can complete
- Resources — capacity, availability and shared state
- Resource Requirements and Resource Leveling — the guides
- Batch Graphs — create tasks, dependencies and resources together
- Conflicts — detect deadline violations and over-allocated resources