Workflow JSON Schema#
This page is the reference for the v3 workflow.json format — the on-disk
representation of a GaP graph. The loader lives in
gap/runtime/workflow.py and the
structural validator in
gap/runtime/validate.py. For how a
loaded graph actually runs, see Executor Semantics.
A GaP workflow is a dual control/data graph of subgraphs. The top level
is a DAG-with-loops of subgraph and end nodes wired by edges and
conditional edges; each subgraph is a self-contained inner graph of
tool / script / router / noop nodes with the same edge vocabulary,
declared typed inputs/outputs, and a small set of typed exit conditions.
Control flow — loops, retries, fallbacks — is expressed entirely as edges
between subgraphs routed on exit conditions. There are no retry counters,
no branch_on constructs, and no magic strings.
Data flows two ways:
Inside a subgraph — tagged-object references (
{"$ref": "node.field.subfield"}) pull values from upstream node outputs.Across subgraphs — by output name: a subgraph declares typed
inputs, and at entry each input binds to the most recent upstream subgraph that declared an output of the same name.
Values on the wire are plain Python: TypedDicts from gap.types and numpy
arrays. There is no in-process serialization layer; the schema’s type-name
strings ("OrientedBoundingBox", "PointCloud", …) resolve through the
gap.schema registry, and node I/O schemas come from Python type-hint
introspection.
Top-level workflow.json#
{
"version": 3,
"meta": {"name": "pick_and_place", "description": "..."},
"nodes": {
"target": {"type": "subgraph", "ref": "target_sg"},
"grasp": {"type": "subgraph", "ref": "grasp_sg"},
"transport": {"type": "subgraph", "ref": "transport_sg"},
"done": {"type": "end", "status": "success"},
"abort": {"type": "end", "status": "failure",
"recovery": [{"tool": "robot.open_gripper"},
{"tool": "robot.go_home"}]}
},
"edges": [["START", "target"]],
"conditional_edges": {
"target": {"router_field": "exit",
"mapping": {"found": "grasp", "not_found": "abort"}},
"grasp": {"router_field": "exit",
"mapping": {"grasped": "transport", "failed": "abort"}},
"transport": {"router_field": "exit",
"mapping": {"placed": "done", "blocked": "abort"}}
},
"subgraphs": {
"target_sg": { "...": "SubgraphDef" },
"grasp_sg": { "...": "SubgraphDef" },
"transport_sg": { "...": "SubgraphDef" }
}
}
Field |
Type |
Required |
Notes |
|---|---|---|---|
|
int |
yes |
Must be |
|
object |
no |
Free-form strings. |
|
object |
yes, non-empty |
Node name → NodeDef. Names |
|
list of |
no |
Static edges. Multiple outgoing edges from one node = parallel fan-out in the same super-step. |
|
object |
no |
Source node → |
|
object |
no |
Name → SubgraphDef. Top level only — subgraphs do not nest. |
Unknown keys anywhere are a hard load-time error (strict-key parsing).
Note
Legacy v2 constructs are rejected with targeted migration messages:
node types
service/skill/policy— rewrite astype: "tool"with a flat dispatch name;recovery entries with
service/methodkeys — rewrite as{"tool": ..., "inputs": ...};the retired
exit.valueskey — split intosuccess_values+on_error.
Subgraph definition (SubgraphDef)#
{
"skill": "perceiving-objects",
"inputs": {"target_obb": "OrientedBoundingBox"},
"outputs": {"target_obb": {"$ref": "filter_obb.obb"}},
"nodes": {
"observe": {"type": "tool", "tool": "robot.get_observation"},
"perceive": {"type": "script", "script": "scripts/target_sg/perceive.py",
"inputs": {"observation": {"$ref": "observe"},
"object_name": "alphabet soup"}},
"filter_obb": {"type": "tool", "tool": "geometry.filter_and_compute_obb",
"inputs": {"points": {"$ref": "perceive.points"}}},
"found": {"type": "noop"}
},
"edges": [["START", "observe"], ["observe", "perceive"],
["perceive", "filter_obb"], ["filter_obb", "found"],
["found", "END"]],
"conditional_edges": {},
"exit": {"router_field": null, "success_values": ["found"]},
"on_error": "not_found"
}
Field |
Type |
Required |
Notes |
|---|---|---|---|
|
string |
yes |
Owning skill bundle (legacy key |
|
object |
no |
|
|
object |
no |
|
|
— |
yes / no / no |
Same shape as the top level. Valid inner node types: |
|
object |
yes |
|
|
string |
no |
The single failure exit value — see the on_error failure exit. |
|
string |
no |
Canonical pick-and-place stage tag; ignored by the runtime. |
Node definition by type (NodeDef)#
Field |
|
|
|
|
|
|
|---|---|---|---|---|---|---|
|
required |
— |
— |
— |
— |
— |
|
— |
required |
required (routing function) |
— |
— |
— |
|
— |
— |
— |
required |
— |
— |
|
yes |
yes |
yes |
yes |
yes |
yes |
|
yes |
yes |
— |
— |
— |
— |
|
— |
— |
— |
— |
— |
required |
|
— |
— |
— |
— |
— |
optional |
tool— dispatches the flat name through theToolRegistry: connector tools (robot.get_observation,sim.check_success), tool-bundle functions (sam3.segment_box,geometry.iou), or a callable skill bundle registered under its own name (pi05-libero.run). Returns whatever the tool returns. See Connector Tools for therobot.*/sim.*surface.script— imports the Python file and calls its typedrun(ctx: NodeContext, ...) -> Outputfunction, whereOutputis a TypedDict (orNone). Resolved workflow inputs not in therun()signature are dropped with a warning; declared output keys missing from the returned dict are an execution error.router— runs its script like a script node, but the returned dict must carry aroutekey: a string (static dispatch — looked up in the node’sconditional_edgesmapping) or a list of{"to", "inputs"}dicts (Send fan-out).subgraph— top level only; recurses into the referenced SubgraphDef (Subgraph node execution).noop— a passthrough marker with empty output; used as a named subgraph terminal (success exit) whenexit.router_fieldis null.end— top level only; terminates the workflow withstatus, after running itsrecoverytool calls best-effort (End nodes and recovery).
The streaming: true flag is valid only on tool and script nodes; it
marks the node as a detached producer that publishes snapshots via
ctx.publish and is consumed via $ref
(Streaming nodes).
Recovery ToolCall#
{"tool": "robot.open_gripper", "inputs": {}}
Recovery actions live outside the node-dispatch system: a short list of
best-effort tool calls run when the workflow lands on an end node. tool is
the same flat dispatch namespace as type: tool nodes; inputs are literal
values (no $ref resolution). Failures are logged and never raise.
Conditional edges#
"conditional_edges": {
"grasp": {"router_field": "exit",
"mapping": {"grasped": "transport", "failed": "abort"}}
}
Source node type |
|
Dispatch value |
|---|---|---|
|
must be |
the routing function’s returned |
|
|
the subgraph’s exit value |
any other |
required string |
that field of the source node’s output |
The resolved value must be a key in mapping — anything else is a runtime
PipelineError. Mapping targets are node names (or END inside subgraphs).
Subgraph exit declaration#
"exit": {"router_field": null, "success_values": ["found", "table_clean"]}
The subgraph’s terminal node is the one whose edge points to END. Two
modes:
router_field: null(the common case): the terminal node’s name becomes the exit value. Every entry ofsuccess_valuesmust be a declarednoopnode — the success exits are literally named markers in the graph.router_field: "<field>"(data-dependent exit): the exit value is read from that field on the terminal node’s output. Success values are returned strings and must not collide with node names.
At runtime the resolved exit value must be a member of
success_values ∪ {on_error}; anything else raises.
The on_error failure exit#
on_error names the exit value emitted when any node in the subgraph
raises. Without it, exceptions propagate and abort the workflow (the
exception surfaces in ExecutionResult.error). With it, the exception is
caught, the declared value becomes the subgraph’s exit condition (bypassing
the terminal-node path), and the top-level conditional edge routes
accordingly — the idiom for “any failure → abort” wiring.
on_error is a pure symbol: it must not be a declared node (S9) and must
not appear as a conditional-edges mapping target inside the subgraph (S10).
Checkpoints are not evaluated on the on_error
path (the postcondition is moot).
Reference syntax ($ref)#
{"$ref": "observe"} // full output of node `observe`
{"$ref": "perceive.points"} // field access
{"$ref": "filter_obb.obb.center.0"} // nested walk + integer index
{"$ref": "in.target_obb"} // bound subgraph input
{"$ref": "in.observation_stream"} // the executor-injected stream
{"$ref": "tracker"} // streaming node → latest() snapshot
Resolution rules (gap.runtime.workflow.resolve_ref):
The head must be a node name in the current scope or the reserved pseudostate
in(bound subgraph inputs). References cannot escape the current subgraph — cross-subgraph data crosses only through declaredinputs/outputs.If the head’s value exposes a callable
.latest()(a streamingStreamSlotor the observation stream), it is invoked transparently to snapshot the latest published value before walking sub-fields.Each subsequent path part is, in order: an integer index into a list/tuple (negative indices allowed), a dict key (checked before attributes, so TypedDict keys like
"items"are never shadowed by dict methods), then an attribute.Inside literal lists, refs resolve element-wise:
["a", {"$ref": "x.y"}]is a legal input value.
A tagged object is used instead of a string DSL so the validator can distinguish refs from literals by structure, and literal lists are never ambiguous with references.
Dataflow typing#
Type-name strings in subgraph
inputsdeclarations ("Se3Pose","PointCloud","Mask", …) resolve through thegap.schemaregistry ofgap.typesTypedDicts. An unknown name fails validation, not mid-execution."Any"/"dict"are open escape hatches;"ObservationStream"marks the injected stream.Tool nodes get their I/O schemas from the registry’s
UnitSchema(extracted from the tool function’s type hints at registration).Script nodes get theirs from
run()’s signature: every non-ctxparameter must carry a type annotation; the return annotation must be a TypedDict (orNone). Supported hint shapes: scalars, TypedDicts,list[...],dict,T | None,Any,ObservationStream, bare classes (np.ndarray).Cross-binding compatibility: same-name structured types match by name;
int/floatinterchange;Any/dictmatch anything; fields whose hints cannot be introspected degrade to lenient. Note that the structural validator currently enforces cross-subgraph wiring by name only (W8); this field-level compatibility relation is defined but not enforced at validation time.
Validation rules#
Validation has two layers:
The loader (
load_workflow) enforces syntax: strict keys at every level, version check, per-type required fields, reserved-name rejection (START/END/in),streamingonly on tool/script, and the v2-leakage migration errors above. Loader violations raiseWorkflowValidationErrorimmediately.The structural validator (
validate_workflow) returns a list ofValidationIssueobjects; the executor hard-fails on anyerror-severity issue before running and logs warnings.
Workflow level#
# |
Severity |
Rule |
|---|---|---|
W1 |
error |
|
W2 |
error |
At least one edge from |
W3 |
error |
Every edge endpoint is |
W4 |
error |
Every non-end node is reachable from |
W5 |
error |
Every node has an outgoing edge or a conditional-edges entry, unless it is |
W6 |
error |
Conditional-edges sources are declared nodes; every mapping target is a declared node or |
W7 |
error |
Every |
W8 |
error |
Every reachable subgraph’s declared input is supplied — by the executor-injected |
— |
error |
At least one |
Subgraph level#
# |
Severity |
Rule |
|---|---|---|
S1 |
error |
At least one edge from |
S2 |
error |
Every node reachable from |
S3 |
error |
Streaming nodes are pure sources: no outgoing edges, no conditional-edges entry. |
S4 |
error |
Streaming-flag/skill-contract consistency: a |
S5 |
error |
|
S6 |
error |
Subgraph |
S7 |
error |
|
S8 |
error |
Conditional edges from a non-router source declare |
S9 |
error |
|
S10 |
error |
|
S11 |
error |
|
— |
error |
Non-streaming, non-end nodes need an outgoing edge or conditional entry. |
— |
error |
Declared input type names resolve in the |
Note
Schema introspection failures degrade to warning-level issues rather
than blocking. In particular, a tool node whose tool is not yet registered
at validate time (e.g. robot.* connector tools that only register when a
connector attaches) produces a warning listing the available tools — typos
in connector tool names surface at runtime, not validation.
At graph-generation time the validator additionally reconciles each
subgraph’s success_values ∪ {on_error} against the owning skill’s declared
exit_conditions (from SKILL.md frontmatter); the executor skips this
check. See Generating Graphs.
Reserved names#
Name |
Where |
Meaning |
|---|---|---|
|
edge endpoint |
Virtual entry: |
|
edge / mapping target |
Virtual exit: an edge |
|
|
Pseudostate holding bound subgraph inputs ( |
|
|
The executor-injected observation stream (when the connector polls). |
|
output key |
Reserved key on a subgraph node’s result carrying its exit value; |