Workflow Instances
A Workflow Instance is a live execution of a workflow — a collection of tasks running toward completion. Instances can be created from a template (inheriting its structure) or built up programmatically by adding tasks and dependencies directly.
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. Template overrides, excludes and role bindings (F8) are merged (#275, 2026-10-12) but not yet deployed to production: the production deployment of that merge failed on Vercel and is awaiting a retry. The rest of this page is live.
When to use client.workflowInstances
- Launching a new workflow when a business event occurs (deal signed, customer created, order placed)
- Monitoring overall workflow status (
running,blocked,completed) - Filtering instances by subject to find all workflows associated with a specific customer or project
- Cancelling a workflow when the underlying business event is voided
Methods
| Method | Description |
|---|---|
create(input) | Launch a new instance (optionally from a template, with overrides, excludes and role bindings) |
createWithGraph(input) | Create a job with its resources, tasks and dependencies in one atomic request, with an optional anchor, direction and leveling |
get(instanceId) | Fetch instance and current status |
cancel(instanceId) | Cancel instance and all open tasks |
recompute(instanceId) | Replan one planner-managed job now and return the fresh schedule summary |
list(opts?) | List instances filtered by project, status, or subject |
workflowInstances.create(input)
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
templateId | string | no | Instantiate from a published template |
projectId | string | no | Defaults to personal project |
name | string | no | Display name |
subjectType | string | no | Opaque label for the triggering entity (e.g., 'customer') |
subjectId | string | no | ID of the triggering entity |
jobValue | number | no | Whole-number value of the job |
metadata | Record<string, unknown> | no | Arbitrary key-value data |
anchorDate | string (ISO 8601) | no | With templateId: the date each template node's offsetFromAnchorMin is measured from (default now). Not the finish anchor below |
anchor | { type: 'finish'; at: string } | no | Required finish of the whole plan. Behind CLOCKWORK_PLANNING_V2 |
schedulingDirection | 'forward' | 'backward' | no | Default 'forward'. 'backward' requires anchor (otherwise 422 ANCHOR_REQUIRED). Behind CLOCKWORK_PLANNING_V2 |
resourceLeveling | 'off' | 'delay' | no | Default 'off'. Behind CLOCKWORK_PLANNING_V2 |
overrides, exclude, excludeMode, roleBindings | no | Customise a template instantiation; need templateId. Behind CLOCKWORK_PLANNING_V2. See Instantiate a template with overrides |
anchor, schedulingDirection and resourceLeveling on create
Behind CLOCKWORK_PLANNING_V2, create stores these three fields on the job with exactly the validation and meaning they have in createWithGraph: anchor.type must be 'finish', anchor.at an ISO 8601 UTC timestamp, and schedulingDirection: 'backward' without an anchor is a 422 ANCHOR_REQUIRED. A job created empty has nothing to plan yet (requiredStart and makespanMinutes stay null); every task you add afterwards is planned against the stored anchor, direction and leveling.
With the flag off the platform behaves as it always did: these fields (and the template options below) are silently dropped. The call returns 201 and the instance comes back with anchor: null, schedulingDirection: 'forward' and resourceLeveling: 'off'. Check the returned instance if your code may talk to a platform without the flag.
The request schema is deliberately not strict: any other unknown field is still ignored without an error, as before, so existing clients that send extra fields keep working. One exception, flag on: assignmentPolicy, resources, tasks and dependencies cannot be honored by create, so they answer 400 UNSUPPORTED_FIELD (the message names the field and points to createWithGraph) instead of being dropped. PATCH (update) answers the same for anchor, schedulingDirection, resourceLeveling and those four. Flag off: ignored, as before. (createWithGraph is strict and answers 400 for an unknown field.)
Example
// Create from a template
const { data: instance } = await client.workflowInstances.create({
templateId: 'tmpl_abc123',
subjectType: 'customer',
subjectId: 'cust_xyz',
})
console.log(instance.id, instance.status) // a UUID, 'running'// Create standalone (no template)
const { data: instance } = await client.workflowInstances.create({
subjectType: 'deal',
subjectId: 'deal_987',
metadata: { region: 'us-west', tier: 'enterprise' },
})Instantiate a template with overrides
Behind CLOCKWORK_PLANNING_V2. With templateId, three optional fields customise what the template's latest version turns into, without publishing a new version:
| Field | Type | Meaning |
|---|---|---|
overrides | Record<string, TemplateNodeOverride | TemplateDependencyOverride> | Keyed by a manifest node key: any of title (alias name), description, durationMinutes, bufferAfterMinutes, isFixed, offsetFromAnchorMin. Keyed by "from->to": { lagMinutes } for the dependency(ies) between those two nodes. Only these fields exist; any other field is a 400. |
exclude | string[] | Node keys to skip: no task is created for them. |
excludeMode | 'bridge' | 'drop' | What happens to the dependencies around an excluded node. Default 'bridge'. |
roleBindings | Record<string, string> | Role slot name -> resource id, bound while the job is created. |
const { data: job } = await client.workflowInstances.create({
templateId: template.id,
anchorDate: '2026-03-02T08:00:00Z',
overrides: { run: { durationMinutes: 120 }, 'prep->run': { lagMinutes: 20 } },
exclude: ['pack'],
roleBindings: { operator: operatorResourceId },
})
console.log(job.applied) // { overrides: ['prep->run', 'run'], excluded: ['pack'], excludeMode: 'bridge', bridgedEdges: 0, droppedEdges: 1, roleBindings: [...], tasks: { prep: '...', run: '...' } }A runnable version is in the repository at docs-examples/f8-template-overrides.mts.
Semantics
- Order of application. Overrides are applied first, then excludes, then the result is scheduled once. Everything that can be rejected is rejected before anything is written.
- Excluding with
bridge(default). Every predecessor of an excluded node is connected to every successor of it, and the new dependency's lag is the sum of the lags along the way, so ordering is preserved. The excluded node's own duration drops out (it no longer takes time). A run of several excluded nodes collapses into one dependency per predecessor/successor pair. The dependency type is kept; if the dependencies on one bridged path have different types the request is a422 BRIDGE_MIXED_TYPES(useexcludeMode: 'drop'). If a bridge duplicates a dependency that already exists (same pair and type), the two merge and the larger lag wins. Excluding a first or last node simply removes its dependencies. - Excluding with
drop. Every dependency touching an excluded node is removed and nothing replaces it, so the remaining nodes may become independent of each other. - Dependency overrides use the lag before bridging, so an overridden lag is the one summed into a bridge (in
bridgemode you may override a dependency that touches an excluded node). Afrom->tokey that matches several dependencies (different types) overrides all of them. - Role bindings use the existing role-slot machinery, the same as
roles.fill: each entry creates an active role assignment for the job, visible inroles.listForInstanceand announced with the usualrole.filledevent. "Bound" means exactly that. It does not assign the nodes' tasks to the resource and does not change the schedule.applied.roleBindings[].nodeKeyslists the surviving nodes whoseroleSlotis that slot (empty if they were all excluded; the binding is still made). - Atomic. The resulting graph is created through the same single-transaction path as
createWithGraph: one schedule computation, andanchor,schedulingDirectionandresourceLevelingare honoured. If anything fails, no job, task, dependency or role assignment is left behind. Only new tasks are created, so nothing already started or completed can move. - Deterministic. The same request produces the same schedule.
anchorDateplus each node'soffsetFromAnchorMinis the earliest a node may start (never earlier than now), as for a plain template instantiation.
Response
When overrides, exclude or roleBindings is present (even empty), the instance comes back with two extra properties; otherwise the response is the plain instance, as before.
| Property | Description |
|---|---|
applied.overrides | Node keys and from->to keys that carried an override |
applied.excluded, applied.excludeMode | What was excluded, and how |
applied.bridgedEdges | Distinct dependencies created by bridging |
applied.droppedEdges | Dependencies removed because they touched an excluded node |
applied.roleBindings | { slotName, resourceId, nodeKeys }[] |
applied.tasks | Node key -> created task id (excluded nodes are absent) |
schedule | The computed schedule, as for createWithGraph; each ref is the node key. Check schedule.conflicts: an unmeetable anchor is still a 201 |
Each created task stores its node key in templateNodeKey.
Errors
| Status / code | When |
|---|---|
404 NOT_FOUND | Template not found in the project; a role-binding resource that does not exist or belongs to another project (resourceIds lists them) |
422 UNKNOWN_REF | Unknown node or dependency key in overrides or exclude, or an undeclared role slot in roleBindings; refs lists all of them |
422 ALL_EXCLUDED | Every node is excluded |
422 OVERRIDE_ON_EXCLUDED | An override for an excluded node (or, with drop, for a dependency touching one) |
422 BRIDGE_MIXED_TYPES | A bridged path mixes dependency types |
422 CYCLE_DETECTED | The manifest has a dependency cycle |
422 ANCHOR_REQUIRED | schedulingDirection: 'backward' without anchor |
422 TEMPLATE_REQUIRED, 422 TEMPLATE_EMPTY | Template options without templateId; the template version has no nodes |
400 VALIDATION_ERROR | Wrong types, an override field that does not exist, a role binding that is not a UUID |
Known limitations
- Manifest nodes have no resource requirements,
maxLagMinutesor per-node role fields, and this feature does not add them:overridescan only change fields that exist on a node (or a dependency'slagMinutes). Usetasks.setRequirementson the created tasks afterwards. - Role slots are the older mechanism that capability requirements are replacing; role bindings use it because that is where templates declare their slots today. A person is bound through their resource (a resource with a
platformUserId). - Instantiation without any of the three template options keeps the original task-by-task path (not atomic). Add
roleBindings: {}to opt in to the atomic path without customising anything. - Overrides apply to one instantiation only; they never change the template or its versions.
- Without the flag, none of this applies and the fields are ignored.
workflowInstances.get(instanceId)
const { data: instance } = await client.workflowInstances.get(instanceId)
console.log(instance.status) // 'running'
console.log(instance.completedAt) // null (still running)workflowInstances.list(opts?)
Options
| Option | Type | Description |
|---|---|---|
projectId | string | Filter by project |
status | WorkflowInstanceStatus | Filter by status |
subjectId | string | Find all instances for a specific subject |
// Find all running onboarding workflows for a customer
const { data: instances } = await client.workflowInstances.list({
subjectId: 'cust_xyz',
status: 'running',
})workflowInstances.cancel(instanceId)
Cancels the instance and marks all open tasks as cancelled. This cannot be undone.
const { data: instance } = await client.workflowInstances.cancel(instanceId)
console.log(instance.status) // 'cancelled'workflowInstances.recompute(instanceId)
Replans one planner-managed job now, by the same path a task change takes (reason recompute), and returns the fresh summary. Needs planning v2.
const { data } = await client.workflowInstances.recompute(instanceId)
console.log(data.requiredStart, data.makespanMinutes, data.conflicts.length, data.changed)- Result.
{ instanceId, requiredStart, makespanMinutes, conflicts, changed };changedis the number of tasks whose planned window moved (0 when nothing changed). Aschedule.changedevent withreason: "recompute"is emitted only when something moved. - Idempotent and safe. Calling it again changes nothing. Started, completed, cancelled and fixed tasks never move. Two simultaneous calls for one job queue behind each other: neither fails and only one emits.
- Which jobs. Planner-managed jobs only (
schedulingDirection: "backward"orresourceLeveling: "delay"). Any other job answers422 NOT_PLANNER_MANAGED, because task changes on those recompute per connected component with the older CPM engine, not as a whole; so does a cancelled job. A completed job is allowed (its tasks are all frozen). - Access. Same as other job changes: a member of the job's organization. A project-bound API key sees only its own project's jobs (
404elsewhere); a guest token gets403; an unknown job is404;404while planning v2 is off. - When to call it. After a resource edit made with
recompute: false, after editing resources directly in a way that skips the API, or to be sure a background replan finished. Resource edits otherwise trigger it for you (see Resources).
Instance status lifecycle
pending → running → completed
↘ failed
↘ cancelled
↘ blocked (all open tasks are blocked)
workflowInstances.createWithGraph(input)
Creates a job with its resources, tasks and dependencies atomically, computes the schedule once, and returns the instance, the ref -> id maps and the full schedule. It is the only way to create the tasks together with an anchor, a scheduling direction and resource leveling in one request (with the platform flag on, create also stores the three fields on an empty job).
POST /api/v1/workflow-instances/graph. Behind theCLOCKWORK_PLANNING_V2flag on the platform: every planning-v2 endpoint returns404while the flag is off (it is off by default). This feature is merged but not yet deployed to production, so the hosted platform does not serve it yet.
A key needs the executions:write scope. For a guided walk-through see Schedule a whole workflow in one call; for adding to an existing job see Batch Graphs.
Example
A print shop. The press has 2 slots and both print runs need both of them. The operator is only needed to load and unload each run (the first and last 10 minutes), not for the whole run. Delivery is due at a fixed time, so the plan is built backward from it, and leveling keeps the press within its 2 slots. Runnable: docs-examples/wi-create-with-graph.mts.
const { data } = await client.workflowInstances.createWithGraph({
instance: {
name: 'Order 4471',
subjectType: 'order',
subjectId: '4471',
jobValue: 1200, // whole currency units
metadata: { customer: 'Acme Books' },
anchor: { type: 'finish', at: due.toISOString() },
schedulingDirection: 'backward',
resourceLeveling: 'delay',
// projectId: '…' defaults to the caller's own project
},
resources: [
{
ref: 'press', name: 'Press', type: 'asset', capacity: 2,
description: 'Two-slot offset press',
availability: [{ from: windowFrom, to: due.toISOString() }],
properties: { model: 'OP-2' },
metadata: { site: 'north' },
externalId: 'press-1',
},
{ ref: 'operator', name: 'Operator', type: 'human', capacity: 1 },
],
tasks: [
{
ref: 'plates', name: 'Make plates', durationMinutes: 30,
requirements: [{ resourceRef: 'operator', units: 1 }],
},
{
ref: 'run', name: 'Print run', durationMinutes: 120,
description: 'Both slots, operator at start and end only',
bufferAfterMinutes: 15, // ink-drying time that counts toward the anchor
tags: ['press'],
priority: 'high',
subjectType: 'order', subjectId: '4471',
metadata: { sheets: 5000 },
resourceRefs: ['press'], // links the resource; it does not claim capacity (see `requirements`)
requirements: [
{ resourceRef: 'press', units: 2 },
{ resourceRef: 'operator', units: 1, occupancy: { mode: 'bookends', startMinutes: 10, endMinutes: 10 } },
],
},
{
ref: 'bind', name: 'Bind and box', durationMinutes: 60,
earliestStart: new Date(Date.now() + 3_600_000).toISOString(), // lower bound for the start
deadline: due.toISOString(),
anchorWindow: { minOffsetMinutes: -10, maxOffsetMinutes: 0, applyTo: 'finish' }, // finish 0 to 10 min before the anchor
requirements: [{ resourceRef: 'operator', units: 1 }],
},
],
dependencies: [
{ from: 'plates', to: 'run' },
{ from: 'run', to: 'bind', type: 'finish_to_start', lagMinutes: 0, maxLagMinutes: 240 },
],
})Request
The body is an object with four keys. Only tasks is required (at least one task). Every object in the request is strict: an unknown field anywhere is a 400 VALIDATION_ERROR, never silently ignored.
| Key | Type | Required | Default | Description |
|---|---|---|---|---|
instance | object | no | {} | The job itself. See instance. |
resources | GraphResourceInput[] | no | [] | Resources to create with the job. At most 100. |
tasks | GraphTaskInput[] | yes | At least 1, at most 500. | |
dependencies | GraphDependencyInput[] | no | [] | At most 2,000. |
ref values are keys you choose (1 to 100 characters). Task refs must be unique among tasks, resource refs unique among resources; a task ref and a resource ref may be equal. Dependencies and requirements point at refs, and the response maps every ref to the id the platform generated.
Timestamps (anchor.at, earliestStart, deadline, availability bounds) are ISO 8601 UTC strings ending in Z, for example 2026-11-26T16:00:00Z. An offset such as +02:00 is rejected with 400.
instance
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
anchor | { type: 'finish'; at: string } | no | none | The required finish of the whole plan ("everything done by 16:00"). type must be 'finish'. |
schedulingDirection | 'forward' | 'backward' | no | 'forward' | 'backward' places every task at its latest start that still meets the anchor. Requires anchor (otherwise 422 ANCHOR_REQUIRED). |
resourceLeveling | 'off' | 'delay' | no | 'off' | 'off' only reports over-allocation of a resource (a resource_capacity_breach conflict). 'delay' resolves it by delaying tasks that have not started. |
assignmentPolicy | 'balance_load' | 'earliest_finish' | no | none | Resolve each requirement on a capability:<id> pool to one member resource, returned in schedule.assignments. Requires resourceLeveling: 'delay' (otherwise 422 ASSIGNMENT_REQUIRES_LEVELING). Omitted: a pool requirement stays a claim on the pool's total capacity. See Assign from a pool. |
name | string | no | null | Display name. |
projectId | string (UUID) | no | caller's project | 404 NOT_FOUND ("Project not found") if it does not exist. |
subjectType, subjectId | string | no | null | Opaque labels for your own entity. |
jobValue | integer | no | null | Whole-number value of the job. |
metadata | Record<string, unknown> | no | {} | Arbitrary key-value data. |
An anchor with schedulingDirection: 'forward' is stored but does not move tasks on its own; it matters to anchorWindow and to backward planning. The job is created with status: 'running' and startedAt set to the time of the request.
resources[]
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
ref | string | yes | 1 to 100 characters, unique among resources. | |
name | string | yes | ||
type | 'human' | 'agent' | 'service' | 'asset' | one of type / resourceTypeId | A request with neither is 400 ("Either type or resourceTypeId is required"). | |
resourceTypeId | string (UUID) | one of type / resourceTypeId | An existing resource type. | |
capacity | positive integer | no | unlimited | Units available at once (the press has 2). A resource without a capacity is never reported as over-allocated. |
description | string | no | null | |
availability | unknown[] | no | always available | Absolute windows { from, to } or { start, end } (ISO 8601). With at least one recognized window the resource can only be used inside their union. An entry that looks like a window but has bad timestamps, or ends before it starts, is a 400. Other entry shapes are stored and ignored by the planner. |
properties, metadata | Record<string, unknown> | no | Stored as given. | |
externalId | string | no | null | Your own id for the resource. |
platformUserId | string (UUID) | no | null | A platform user id stored on the resource. |
stateKey, stateTolerance | string, number >= 0 | no | Shared state the resource tracks (a temperature, a setting). Must be given together. Claims may overlap only when their state values are within the tolerance. | |
stateSetupMinutes | integer >= 0 | no | Changeover minutes between claims whose states differ by more than the tolerance. Needs stateKey. |
tasks[]
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
ref | string | yes | 1 to 100 characters, unique among tasks. | |
name | string | yes | Non-empty. | |
description | string | no | ||
durationMinutes | integer >= 0 | no | 0 | |
durationSeconds | integer >= 0 | no | When set, used instead of durationMinutes. | |
isFixed | boolean | no | false | A fixed task never moves: it is planned at its earliestStart. |
earliestStart | ISO UTC string | no | time of the request | Lower bound for the start. |
deadline | ISO UTC string | no | none | A task that cannot finish by it raises a deadline_violated conflict. |
assigneePlatformUserId | string (UUID) | no | none | |
bufferAfterMinutes | integer >= 0 | no | 0 | Gap kept after the task. Counts toward the anchor. |
bufferAfterSeconds | integer >= 0 | no | When set, used instead of bufferAfterMinutes. | |
tags | string[] | no | [] | |
priority | 'high' | 'medium' | 'low' | null | no | null | |
subjectType, subjectId | string | null | no | null | |
metadata | Record<string, unknown> | no | ||
resourceRefs | string[] | no | [] | Refs of resources in this request assigned to the task. This links the resource to the task; it does not claim capacity. Use requirements for that. An unknown ref is 422 UNKNOWN_REF. |
requirements | GraphRequirementInput[] | no | [] | Units-and-occupancy claims on resources. At most 50 per task. |
anchorWindow | object | no | none | Shorthand for an anchor_window constraint on this task. Requires instance.anchor (422 ANCHOR_REQUIRED). |
anchorWindow:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
minOffsetMinutes, maxOffsetMinutes | integer | at least one | Offsets from the anchor in minutes; negative is before it. minOffsetMinutes must not exceed maxOffsetMinutes. | |
applyTo | 'finish' | 'start' | no | 'finish' | Whether the window bounds the task's finish or its start. |
requirements[]: set exactly one of resourceRef, resourceId or poolId.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
resourceRef | string | one target | A resource created in this request. | |
resourceId | string (UUID) | one target | An existing resource. 422 UNKNOWN_REF if it does not exist. | |
poolId | string | one target | A pool id such as capability:{capabilityId}. By default a requirement claims capacity on the pool as a whole; with instance.assignmentPolicy the planner picks one member of the pool for it. | |
units | positive integer | no | 1 | Must not exceed the resource's capacity (422 REQUIREMENT_EXCEEDS_CAPACITY). |
occupancy | object | no | { mode: 'full' } | When within the task the units are held: { mode: 'full' } (the whole duration), { mode: 'bookends', startMinutes, endMinutes } (only the first and last minutes; both required, integers >= 0) or { mode: 'none' } (never; declared for visibility). |
state | number | no | The shared-state value this claim needs. The resource must declare stateKey (422 STATE_NOT_SUPPORTED otherwise); not valid on a pool. |
dependencies[]
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
from, to | string | yes | Task refs. from is the predecessor. | |
type | 'finish_to_start' | 'start_to_start' | 'finish_to_finish' | 'start_to_finish' | no | 'finish_to_start' | |
lagMinutes | integer | no | 0 | Minimum gap. May be negative (overlap). |
lagSeconds | integer >= 0 | no | When set, used instead of lagMinutes. | |
maxLagMinutes | integer | no | none | Maximum gap, >= lagMinutes (else 400). The planner moves the predecessor later to keep the gap within it; when it cannot (for example the predecessor is isFixed) the plan keeps the long gap and reports a max_lag_exceeded conflict. |
Response
201 Created with { data }. data has exactly four keys:
| Key | Type | Description |
|---|---|---|
instance | WorkflowInstance | The created job, including anchor, schedulingDirection, resourceLeveling, requiredStart and makespanMinutes. |
refs | Record<string, string> | Task ref to task id. |
resourceRefs | Record<string, string> | Resource ref to resource id. Empty when the request created no resources. |
schedule | GraphSchedule | The computed schedule. |
schedule has exactly these keys:
| Key | Type | Description |
|---|---|---|
tasks | GraphScheduleTask[] | One row per task, sorted by ref. |
requiredStart | string | null | See below. |
finish | string | null | The latest planned task end (buffers after a task are not included). |
makespanMinutes | number | See below. |
criticalPath | string[] | Task ids of the chain that determines the finish, in planned order. It is a single path, and exists even when an anchor leaves every task with slack. |
utilization | ResourceUtilization[] | One row per capacity-limited resource, sorted by resource id. |
conflicts | GraphConflict[] | Everything the plan could not satisfy. Empty when the plan is clean. |
assignments | PoolAssignment[] | Only with an assignmentPolicy. { ref, taskId, requirementId, poolId, assignedResourceId } for every pool requirement that got a member, sorted by task ref. A requirement no member can hold is absent. |
schedule.tasks[] has the keys ref, name, taskId, plannedStart, plannedEnd, earliestStart, latestStart, slackSec and isCritical:
| Field | Description |
|---|---|
plannedStart, plannedEnd | Where the plan placed the task (whole seconds). |
earliestStart | The earliest the task could start: your lower bound pushed by its predecessors. |
latestStart | The latest it could start and still meet the anchor or deadline. |
slackSec | latestStart - earliestStart in seconds. Negative when the plan cannot be met. |
isCritical | true when slackSec <= 0. This is per task, so it can be false for every task of a plan whose anchor leaves slack, while criticalPath still lists the driving chain. |
schedule.tasks[].namecurrently echoes the task'sref, not itsname. Read the name from the task itself, or from the request you sent. (scheduling.simulatereturns the real name.)
schedule.utilization[] has resourceId, resourceRef, capacity, occupiedUnitMinutes (units times occupied time, so a bookends claim counts only its first and last minutes), availableUnitMinutes (capacity times the plan span), utilization (occupied divided by available, above 1 only when over-allocated) and peakUnits (the most units in use at any instant).
schedule.conflicts[] has taskId, ref, type, description, slackViolationSec, and for anchor_infeasible also shortfallMinutes. Types you can see here: deadline_violated, resource_capacity_breach, constraint_violated, anchor_infeasible and max_lag_exceeded. A plan that cannot be met is still a 201: check schedule.conflicts on every response. The same conflicts are persisted and can be listed with conflicts.list().
requiredStart and makespanMinutes
requiredStart: the earliestplannedStartamong the job's tasks that are not cancelled, as a UTC timestamp with milliseconds. In other words, when the first piece of work must begin. In a backward plan it is the latest moment the job can start and still meet the anchor; in a forward plan it is when the first task starts (the request time, or the largestearliestStartbound of the first task). It isnullonly when there are no live tasks, which a successfulcreateWithGraphnever produces. When a backward plan cannot meet its anchor,requiredStartfalls back to the earliest start the tasks' lower bounds allow (the request time unless you setearliestStart) and ananchor_infeasibleconflict reports the shortfall.makespanMinutes: whole minutes fromrequiredStarttofinish(the latest planned task end), rounded to the nearest minute (225.0 stays 225, 3.75 becomes 4). Gaps between tasks (lags, buffers between tasks, waiting for resources) are inside it; a buffer after the very last task is not.schedule.makespanMinutesis0for an empty plan. The instance'smakespanMinutesisnullwhen the instance has never been planned as a whole, for example one made withcreate.
The same two values are stored on the instance (instance.requiredStart, instance.makespanMinutes). They are refreshed whenever an anchored or leveled job is re-planned (after a delay, a completion, an added task). For a plain forward job they keep the values from creation, and its tasks' plannedStart / plannedEnd are likewise a snapshot from creation: read earliestStart for the current forward schedule.
Atomicity and rollback
The request is validated entirely in memory first: field validation, then reference, duplicate, anchor, capacity and cycle checks, then the schedule is computed. Only if all of that passes are rows written, in one database transaction (job, resources, tasks, constraints, requirements, dependencies, change log and any conflicts). If any step fails nothing is created: a 4xx leaves the database untouched, and a failure inside the transaction rolls everything back and returns 500 INTERNAL_ERROR. Events (workflow.started, and conflict_detected when the plan has conflicts) and usage meters are emitted after the commit, so a failed request emits nothing.
How many schedule computations?
Exactly one, however many tasks and dependencies the request holds (a single planSchedule pass; the service counts it once). Building the same job task by task would recompute the schedule after every task and every dependency. The platform's own test asserts a 150-task, 250-dependency request produces exactly one computation. This is an internal count, not something an HTTP client can observe. A job created this way does not emit schedule.changed; that event describes later changes.
Errors
| Status | code | When | Extra fields |
|---|---|---|---|
400 | VALIDATION_ERROR | Malformed or missing body, an unknown field, a value out of range or of the wrong type, more than the maximum tasks, dependencies or resources, a +02:00-style timestamp | details.issues[] (each with path and message) |
401 / 403 | UNAUTHORIZED / FORBIDDEN | No credentials, or the key lacks executions:write | |
404 | NOT_FOUND | The planning flag is off ("Not found"), or instance.projectId does not exist ("Project not found") | |
422 | UNKNOWN_REF | A dependency names a task ref that is not in tasks; a task names a resource ref that is not in resources; a resourceId does not exist | refs |
422 | DUPLICATE_REF | Two tasks, or two resources, share a ref | refs |
422 | CYCLE_DETECTED | The dependencies form a cycle (a task depending on itself is one) | refs (the tasks on the cycle) |
422 | ANCHOR_REQUIRED | schedulingDirection: 'backward', or a task anchorWindow, without instance.anchor | refs (the tasks, or empty) |
422 | REQUIREMENT_EXCEEDS_CAPACITY | A requirement asks for more units than the resource's capacity | refs (task, resource) |
422 | STATE_NOT_SUPPORTED | A requirement gives a state on a pool, or on a resource with no stateKey | refs |
422 | ASSIGNMENT_REQUIRES_LEVELING | instance.assignmentPolicy without resourceLeveling: 'delay' | refs (empty) |
429 | RATE_LIMITED | The key's per-minute budget is used up | |
500 | INTERNAL_ERROR | A failure inside the transaction (everything rolled back) |
Exact messages seen, for matching in logs:
422 UNKNOWN_REF Dependencies reference unknown task refs: ghost
422 UNKNOWN_REF Tasks reference unknown resource refs: nope, nope2
422 DUPLICATE_REF Duplicate task refs: a
422 DUPLICATE_REF Duplicate resource refs: r
422 CYCLE_DETECTED Dependency cycle: a -> b -> c -> a
422 ANCHOR_REQUIRED schedulingDirection 'backward' requires instance.anchor
422 ANCHOR_REQUIRED anchorWindow requires instance.anchor (tasks: a)
422 REQUIREMENT_EXCEEDS_CAPACITY Task "a" requires 3 units of "p", which has capacity 2
400 VALIDATION_ERROR (no message field; see details.issues, e.g. "Unrecognized key(s) in object: 'bogus'")Every problem found by the reference, duplicate, anchor, capacity and state checks is listed in errors (each with code, message, refs); the top-level code, message and refs describe the first. Cycle detection only runs when those checks found nothing. In the SDK these arrive as a ClockworkError with status, code and message; the extra fields are in details (so details.refs). A 400 has no message field, so the SDK's message is HTTP 400: read details.issues. A body that is not valid JSON is read as an empty object and fails as 400 with a "Required" issue on tasks.
Limits, request size and rate limits
| Limit | Value | Over the limit |
|---|---|---|
| Tasks per request | 500 (at least 1) | 400 VALIDATION_ERROR, "Array must contain at most 500 element(s)" |
| Dependencies per request | 2,000 | 400 VALIDATION_ERROR, "Array must contain at most 2000 element(s)" |
| Resources per request | 100 | 400 VALIDATION_ERROR, "Array must contain at most 100 element(s)" |
| Requirements per task | 50 | 400 VALIDATION_ERROR |
ref length | 100 characters | 400 VALIDATION_ERROR |
A request at the maximum (500 tasks, 2,000 dependencies, 100 resources) succeeded in under a second on a local server; its response was about 200 KB. The platform code sets no request-body size limit of its own; the limit that applies to a deployed request is the hosting platform's (not verified here).
The call is one request toward your rate limit: the limiter is checked once per HTTP request, before the handler runs, and a 500-task graph costs the same as a one-task graph. Success responses and 400s carry the RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset headers; a 422 currently does not. Without a configured rate-limit store the platform fails open and never returns 429.
Type reference
type WorkflowInstanceStatus = 'pending' | 'running' | 'blocked' | 'completed' | 'failed' | 'cancelled'
type SchedulingDirection = 'forward' | 'backward'
type ResourceLeveling = 'off' | 'delay'
interface WorkflowInstance {
id: string
templateId: string | null
projectId: string
name: string | null
status: WorkflowInstanceStatus
startedAt: string | null
completedAt: string | null
subjectType: string | null
subjectId: string | null
jobValue: number | null
requiredStart?: string | null // earliest planned task start from the last whole-job plan; null if never planned
makespanMinutes?: number | null // minutes from requiredStart to the last planned end; null if never planned
anchor?: { type: 'finish'; at: string } | null // required finish of the whole plan; set only by createWithGraph
schedulingDirection?: SchedulingDirection // 'forward' unless set by createWithGraph
resourceLeveling?: ResourceLeveling // 'off' unless set by createWithGraph
assignmentPolicy?: AssignmentPolicy // present only when set by createWithGraph
metadata?: Record<string, unknown> | null
createdAt: string
updatedAt: string
}
interface CreateWorkflowGraphInput {
instance?: {
anchor?: { type: 'finish'; at: string }
schedulingDirection?: SchedulingDirection
resourceLeveling?: ResourceLeveling
assignmentPolicy?: AssignmentPolicy // 'balance_load' | 'earliest_finish'; needs resourceLeveling 'delay'
projectId?: string
name?: string
subjectType?: string
subjectId?: string
jobValue?: number
metadata?: Record<string, unknown>
}
resources?: GraphResourceInput[]
tasks: GraphTaskInput[]
dependencies?: GraphDependencyInput[]
}
interface CreateWorkflowGraphResult {
instance: WorkflowInstance
refs: Record<string, string> // task ref -> task id
resourceRefs: Record<string, string> // resource ref -> resource id
schedule: GraphSchedule
}
interface GraphSchedule {
tasks: GraphScheduleTask[]
requiredStart: string | null
finish: string | null
makespanMinutes: number
criticalPath: string[]
utilization: ResourceUtilization[]
conflicts: Array<{
taskId: string; ref: string; type: string; description: string
slackViolationSec: number
shortfallMinutes?: number // anchor_infeasible only
}>
}
interface GraphScheduleTask {
ref: string; name: string; taskId: string
plannedStart: string; plannedEnd: string
earliestStart: string; latestStart: string
slackSec: number; isCritical: boolean
}
interface ResourceUtilization {
resourceId: string; resourceRef?: string; capacity: number
occupiedUnitMinutes: number; availableUnitMinutes: number
utilization: number; peakUnits: number
}The request types (GraphResourceInput, GraphTaskInput, GraphRequirementInput, GraphDependencyInput, AnchorWindowInput) are exported from @clockwork/sdk and match the tables above.
Related
- Workflow Templates — define reusable blueprints
- Schedule a whole workflow in one call — a guided walk-through of
createWithGraph - Batch Graphs — add tasks and dependencies to an existing job in bulk
- Anchored Scheduling and Resource Leveling — what the anchor and
resourceLevelingdo - Schedule Simulation — preview a graph or a change without creating anything
- Tasks — add tasks to an instance
- Roles — fill role slots after instantiating a template
- Events — stream real-time lifecycle events