Open Workflow GraphPre-release
Specification

Tasks and executors

The task object in full — executors, provenance edges, dependencies, failure and compensation, retry, gates and re-routes.

View as markdownMachine-readable source for agents and scripted implementers

A task is the unit of work, of billing, and of observability. Everything else in a workflow exists to be consumed or produced by one.

{
  "id": "normalize",
  "label": "Normalise loudness and encode MP3",
  "type": "work",
  "executor": { "type": "service", "ref": "transcode_from_url",
                "environment": "saas" },
  "ai_role": "none",
  "performed_by": "transcode_svc",
  "ran_on": "cloud_platform",
  "depends_on": ["edit"],
  "used": ["edited_wav"],
  "produced": ["episode_mp3"],
  "failure_mode": "CONTINUE",
  "billing": { "tier": "standard", "unit": "per_minute" },
  "retry": { "max_attempts": 2, "initial_interval_seconds": 5 }
}

Only id is required by the schema. In practice a task that omits executor, performed_by, or its provenance edges is a task that cannot be executed or audited — see the pre-flight checklist.

Identity and classification

FieldTypeNotes
idstringRequired. Pattern ^[a-zA-Z0-9_-]+$
labelstringHuman-readable display name
descriptionstringMax 500 chars
typestringOpen set. Common: work, qc, review, delivery, ingest, handoff
toolstringTool name; should match an executor declared in an active profile
metadataobjectFreeform. See Extensions

qc and review are gate types — they carry the routing fields under Gates and re-routes. A review task may carry editorial_signoff. delivery is the type the disclosure gate blocks.

Provenance edges

These four fields are the heart of the model, and they are authoritative: the graph's lineage comes from here and nowhere else.

FieldTypePROV mapping
performed_bystring (participant id)wasAssociatedWith
usedstring[] (asset ids)used
producedstring[] (asset ids)generated (inverse)
ran_onstring (infrastructure id)owg:ranOn

An asset's own produced_by field is a derived convenience for readers walking the graph from the asset side. The task's produced[] is the source of truth, and validation enforces that the two agree.

A task with no used is legitimate — it is an origin task, capturing or generating something that did not exist before.

Only assert an edge you can guarantee. Listing an asset in used[] claims the task consumed it. If the producing task is conditional, gate on exists() and pass the value through inputs instead — see the worked pair.

Executors

The executor is what generalizes "call tool X" into an attribute, and it is the design choice that puts human, machine, and AI work in one accountable graph.

{ "executor": { "type": "comfyui_graph", "ref": "graphs/sdxl_txt2img.json",
                "environment": "cloud" } }
FieldTypeNotes
typeenumRequired. See below
refstringTool, application, or graph reference
environmentenumcloud | saas | desktop | on_prem | manual
credentials_keystringNames a secret held by the orchestrator
permissionsstring[]e.g. ["read:assets", "write:outputs"]

executor.type is one of nine values:

ValueMeaning
serviceA deterministic software service
mcpA tool invoked over the Model Context Protocol
saas_apiAn external SaaS API
comfyui_graphA whole node-graph, treated as one task with a zoomable interior
comfyui_nodeA single node within such a graph
local_appA desktop application on a workstation (Nuke, Houdini, Resolve)
render_farmA batch job on an on-premises or cloud farm
agentAn autonomous agent
humanA person

Two rules that matter:

  • AI involvement is never inferred from executor.type. An agent executor may be doing deterministic file shuffling; a saas_api executor may be running a diffusion model. Declare ai_role explicitly.
  • Secrets are referenced, never inlined. credentials_key names a secret the orchestrator holds, so the document itself stays safe to commit and to hand to a vendor.

Dependencies

Dependencies are a list of task ids, with AND semantics — a task becomes eligible when all of its dependencies have finished.

{ "id": "publish", "depends_on": ["edit", "thumbnail_review"] }

There is no edge object, no port-level wiring, and no cross-document dependency form. Ordering within tasks[] does not govern execution; depends_on does.

The dependency graph must be acyclic. Acyclicity cannot be expressed in JSON Schema, so it is a validator rule.

Skip propagates: a task depending on a skipped task is itself skipped. That is what stops an optional branch from stalling the graph.

Conditional execution

{ "when": "$.params.add_captions == true" }

If the expression is false, the task is skipped. The expression language is small and fully specified — grammar, precedence, type rules, and the exists() function are in Reference syntax.

Unlike everything downstream of a run, when is checked at validation time: an unresolvable reference or a type-mismatched comparison is a validation error, not a run-time surprise.

Failure and compensation

{ "failure_mode": "SKIP_DEPENDENTS" }
ValueBehaviour
HALTStop the entire workflow. The default
CONTINUEMark this task failed, let other branches proceed
SKIP_DEPENDENTSFail this task and skip everything downstream of it
COMPENSATERun the named compensation task, then halt

HALT is the schema default, so omitting failure_mode is safe — but declaring it makes intent explicit, which matters on a task where a reader would reasonably expect something gentler.

Compensation

COMPENSATE requires on_failure_compensate, naming the task that undoes partial work:

{
  "id": "upload_master",
  "type": "delivery",
  "executor": { "type": "saas_api" },
  "performed_by": "delivery_api",
  "used": ["master_prores"],
  "produced": ["delivery_receipt"],
  "failure_mode": "COMPENSATE",
  "on_failure_compensate": "rollback_partial_upload"
}

Two validator rules keep this honest: COMPENSATE without on_failure_compensate is an error, and on_failure_compensate naming a task that does not exist is an error. The compensation task is an ordinary task — it can have its own executor, participant, and cost.

Fan-out (v0.91)

{
  "id": "generate_all_idents",
  "executor": { "type": "agent" },
  "subgraph": { "format": "owg", "ref": "graphs/generate_ident.owg.json" },
  "fan_out": {
    "over": "$.params.shot_list",
    "as": "shot",
    "max_concurrency": 500,
    "tolerated_failure_percentage": 3
  }
}

fan_out runs one task — and its subgraph, if it has one — once per item in a list, concurrently, instead of once. It exists for the case depends_on and failure_mode were never meant to cover: not "these five named tasks," but "this same subgraph, several hundred times, over a list only known at run time."

FieldTypeNotes
overstringRequired. A $. reference resolving to an array — $.params.shot_list, or $.tasks.list_shots.outputs.items
asstringName bound to the current item inside this instance, referenced as $.fan_out.<as>. Defaults to item
max_concurrencyinteger ≥ 1Upper bound on instances running at once. This only lowers an engine's own ceiling — it can never raise one
tolerated_failure_percentagenumber, 0–100The fan-out task is treated as succeeded if no more than this percentage of instances fail
tolerated_failure_countinteger ≥ 0Same idea, as an absolute count. Mutually exclusive with the percentage — declaring both is OWG_FANOUT_TOLERANCE_CONFLICT

Why this needed its own field, not just failure_mode: CONTINUE

failure_mode: CONTINUE (see above) already isolates one task's failure from its siblings — and still should be your first reach for a handful of named parallel branches, like the worked pair of a video path and an audio path. What it does not give you is a threshold: with five hand-authored sibling tasks, "did enough of them succeed" is a judgment call a human makes reading the run. With 500 instances of the same subgraph, nobody is reading 500 rows — the run itself has to be able to say "497 of 500 succeeded, that's a pass" without a human deciding it after the fact.

That is the one thing fan_out adds that composing existing fields cannot: a declared, checkable pass/fail line for a whole batch, evaluated by the engine, not eyeballed afterward.

Addressing instances

Each instance is addressable in run records as $.tasks.<id>[<index>] rather than $.tasks.<id> — see Run records for how attempts are recorded per instance, and how a fan-out task's own succeeded/failed status is derived from its tolerance.

What fan_out does not change

  • used/produced still describe the task once, not once per instance — they name the kind of thing consumed and produced, not each instance's specific asset. Per-instance lineage lives in the run's attempt records.
  • A fan-out task can still declare failure_mode. It governs what happens to this task's own dependents if the fan-out as a whole fails (tolerance exceeded) — the same semantics as any other task, layered on top of, not instead of, the tolerance check.
  • max_concurrency is a ceiling an author states, not a capacity an author is promised. An engine may run fewer instances at once than the ceiling allows — for its own rate limits, quota, or scheduling reasons — but must never exceed it. See Implementation considerations.

Retry

{
  "retry": {
    "max_attempts": 3,
    "initial_interval_seconds": 10,
    "backoff_coefficient": 2.0,
    "retryable_errors": ["RATE_LIMIT", "QUOTA_EXCEEDED", "TRANSIENT_ERROR"]
  }
}

Delay before attempt n is initial_interval_seconds × backoff_coefficient ^ n.

FieldRangeDefault
max_attempts1–101
initial_interval_seconds0.1–36001
backoff_coefficient1.0–10.01.0
retryable_errorsstring[]empty — any error retries

retryable_errors values are executor-defined; the specification does not enumerate error codes, because they belong to whatever system the executor calls. Declare them in your profile.

Retries create new task attempts under the same run. A task that failed twice and succeeded on the third try is three attributed activities, not one overwritten record — which is why $.task.attempt is addressable.

Gates and re-routes

QC and review tasks route on outcome rather than merely succeeding or failing. This is the mechanism that makes a human review gate a first-class part of an automated pipeline, and it is what lets an agent be held to a standard rather than merely invoked.

{
  "id": "qc_review",
  "type": "qc",
  "executor": { "type": "service", "ref": "qc_validate" },
  "performed_by": "qc_svc",
  "depends_on": ["grade"],
  "used": ["graded_master"],
  "checks": ["duration_match", "codec_compliance", "audio_sync"],
  "on_pass": "deliver",
  "on_fail": "regrade",
  "max_reroutes": 3,
  "reroute_feedback": { "to_task": "regrade", "carry": ["qc_notes"] }
}
FieldTypePurpose
checksstring[]Named checks the gate evaluates. Profile-defined
on_passstringTask id to route to on pass
on_failstringTask id to route to on fail
max_reroutesinteger ≥ 0Per-gate re-route ceiling
reroute_feedbackstring | objectWhere feedback goes, and what context travels with it
editorial_signoffobject{ editor (required), statement, timestamp }

reroute_feedback in object form takes to_task and carry — an array of output keys that travel back to the target task, so the agent being asked to try again receives the notes explaining why.

Two ceilings apply, and both are needed: max_reroutes bounds one gate, and governance.max_total_reroutes bounds the whole run. Without the run-level ceiling, three gates each permitting three re-routes can still loop far longer than anyone intended.

Billing

{ "billing": { "tier": "standard", "unit": "per_minute" } }

The core specification imposes no billing vocabulary — tier and unit are free strings, defined by the active profile. This is deliberate: pricing models change faster than schemas should.

Timeouts

timeout_seconds bounds one task, minimum 1. A whole-workflow ceiling is set at the document level.

Data flow

Tasks pass values through inputs and outputs.

{
  "id": "web_delivery",
  "executor": { "type": "service", "ref": "transcode_video" },
  "depends_on": ["ingest"],
  "inputs": {
    "input_key": "$.tasks.ingest.outputs.output_key",
    "output_format": "webm"
  },
  "outputs": {
    "output_key": "jobs/${workflow.id}/${task.id}/delivery.webm",
    "content_type": "video/mp4"
  },
  "used": ["normalized_mp4"],
  "produced": ["delivery_webm"]
}

outputs recognises four conventional keys — output_key, content_type, duration_seconds, file_size_bytes — and permits any others your profile declares. The last two are set by the executor at run time rather than authored.

Keep the distinction clear: inputs/outputs carry values; used/produced assert lineage. A task typically has both, and they describe different things — the input is a storage key, the used asset is the governed object that key belongs to.

Reference syntax, interpolation, and escaping are specified in Reference syntax.