Authoring a workflow
Wire skills into a runnable protocol: node types, inputs and parameter binding, canvases, and the file format.
A workflow is a graph of skills. You wire one skill's completion to the next skill's start, declare the values an operator supplies before a run, bind those values to skill parameters, and end up with a runnable protocol. Workflows live in your project at workflows/<name>.json.
Read this page top to bottom the first time and you will have a workflow you can run. After that, treat the later sections as lookup.
Create a workflow
From inside your project, on your own machine:
zeon new workflow my_workflowThat writes workflows/my_workflow.json. workflow is one of five scaffoldable item kinds: skill, workflow, world, object, canvas. See the CLI reference. Push it with zeon sync -m "add my_workflow" so the cloud has it.
You can also create one from the app. The controls for that, and for everything else described on this page, are laid out in The Workflow Editor.
Build the graph
A workflow is a directed graph. Execution enters at the start node and follows edges from node to node.
Three node types are creatable: a Start node (the graph entry point), Skill nodes (each one executes a robotic skill), and an End node (the graph exit point). Start and End cannot be deleted. A new skill node arrives with no skill chosen, so adding a step is two decisions: put a skill node in the graph, then say which skill it runs.
Most workflows are a single linear chain: start → step 1 → step 2 → … → end.
What an edge condition means
Every edge carries a condition, and it is chosen from the source node:
- A connection drawn from a skill node defaults to
on_success. The target node runs only if the source node completed without error. - A connection drawn from the Start node is unconditional. It always fires. On disk that is written as
default.
The distinction matters. An unconditional edge between two skill steps means the chain continues even after a step fails. Keep on_success between steps unless you specifically want a failure to carry on.
Configure a skill node
A skill node carries four things you decide:
- Its label, the name shown on the node in the graph and in the run log. Renaming it does not change which skill runs.
- A description, free text explaining the step. Worth writing. A description is what makes a fifteen-node graph legible six months later.
- The skill it runs, picked from the skills in your project. Choosing one sets the node's skill and, if you never renamed the label, renames the label to match.
- A value for each of that skill's parameters.
A skill node stores the name of a skill. At run time the platform looks that name up in the project's skills/ folder and calls the Python function in that skill's robotic_code.py. The parameter list you see is derived live from that function's signature: name, type, required and default all come from there. Change the signature, save the skill, and the node's parameter list changes with it. A skill whose function takes no parameters gives you nothing to fill in.
metadata.yaml contributes description text only, through an optional parameter_descriptions map that takes precedence over the docstring's Args: entry. See skills/<id>/metadata.yaml.
Each parameter gets an input control matched to its declared type: a pick from the world's objects for object, a number field for int and float, a checkbox for boolean, a text field for string.
Parameters are not optional on a skill node in the file format. Every skill node carries a parameters map, even an empty one.
Fixed value or input reference
Every parameter takes its value from one of two sources:
- A workflow input. The parameter is bound to an input by name, which writes a
$inputreference into the file. The value is filled in at run time from what the operator supplies. - A fixed value. You type or pick a value and it is baked into the workflow. Every run uses it.
This split is what makes a workflow reusable. Anything constant about the procedure goes in as a fixed value. Anything the operator decides on the day becomes an input reference.
The inputs offered for binding are filtered by type, not listed exhaustively. An input can be bound to a parameter only if:
- its type matches the parameter's type exactly, or
- it is an
intand the parameter is afloat, or - the parameter is an array, which for now accepts any input type.
If no input passes that filter, there is no source choice to make and you get the fixed-value control by itself. A parameter that looks like it has lost its source picker usually means a type mismatch, not a missing input.
There is no "use the previous node's output" source. A skill node's parameters bind to workflow inputs or to fixed values, nothing else. Skills that need to pass work forward do it in code: the runtime gives every skill
set_skill_variable(key, value)andget_skill_variable(key, default), a shared store that lives for the length of one execution. An earlier step stores a value under a key, a later step reads it back under the same key. The store is not reset between skills, and it does not survive into the next run. See Authoring a skill.
Position offset
An object parameter also carries a position offset. It writes offset.xyz in metres, applied relative to the resolved object's pose. This is how a skill targets a point near an object rather than its origin.
The offset also has a rotation quaternion, offset.wxyz, ordered [w, x, y, z]. Only X/Y/Z are editable in the app. Any existing quaternion is carried through untouched, and the default is identity [1, 0, 0, 0].
Declare workflow inputs
An input is a value the workflow asks for before it runs. Each one has a name, a type, an optional description, and a flag for whether it takes a list. Names must start with a letter and contain letters, numbers and underscores only, and they must be unique within the workflow. You declare them in the editor's schema tab, described in The Workflow Editor.
The five types, with the labels shown in the app:
| Label in editor | Value on disk | Use it for |
|---|---|---|
| String | string | Free text. |
| Integer | int | Whole numbers. |
| Float | float | Decimal numbers. |
| World Object | object | A pick from the objects present in the loaded world. |
| Structured Data | structured | A record whose fields you define yourself. |
Write the description as a prompt, not as a note to yourself. It is the label the operator reads when the run asks for a value.
There is no boolean workflow input. The gateway restricts an input's
typeto those five values and rejects anything else.booleanappears in some older workflow files. Such a file still opens, but the type is not offered when you author an input, and the gateway rejects the value on the strict save path. A skill parameter may be a boolean. That is a different thing, declared in the skill'srobotic_code.pysignature, and it is set manually rather than bound to an input.
Renaming an input does not rewrite the skill nodes that reference it by the old name. After a rename, revisit the affected nodes and bind each affected parameter to the input again.
Arrays and item schemas
Marking an input as an array sets is_array on it, and the operator is asked for a list rather than a single value. Two behaviours are worth knowing before you do it.
Marking a World Object input as an array silently converts its type to Structured Data. The two are not independently selectable. An array of world objects is modelled as structured data, and defining a schema on an object array converts the type the same way.
Only array inputs of type World Object or Structured Data can carry a schema, which describes the shape of one item in the list.
The schema lands in the file as itemSchema, a map of field name to { type, isArray?, itemSchema? }. It nests recursively, so an array item can itself contain arrays and sub-objects.
Choose a world
A workflow runs against a world: the scene, its objects, and their poses. Selecting a world and loading it into the simulator is a top-bar action in the editor. See The Workflow Editor.
Load the world before you supply a World Object input, because the list of objects you choose from is read from the loaded world.
Worlds are authored on a different screen. See Building a world.
Save
Saving assembles the whole workflow (graph, edges, node parameters, input schema, canvas reference) into the workflow JSON, writes it into your project, and commits it in one move. There is no separate publish step.
The write and the commit can come apart. If the file was written but the commit did not reach the cloud, the app warns you and the warning stays until you dismiss it, because it means your teammates cannot see the change yet. See How sync works.
To bring the change back to your machine, run zeon sync.
Run it
Supplying values and starting a run both happen in the editor. For the controls, see The Workflow Editor. For a first-run walkthrough, see Running a workflow in the cloud sim.
Two things about how a run behaves are worth knowing while you are still designing.
The world is reloaded before every run. A run moves objects in the live world and does not persist those moves, so without a reload a second run would start from wherever the first one left off. Reloading restores the world's saved poses. Object identities survive the reload, so the values you picked stay valid. The reload takes several seconds and execution does not start until the scene has settled, so a pause between starting a run and the first movement is expected.
The cloud runs in simulation. Executing against real hardware is a local-platform capability, not something the cloud does. See Running a workflow on real hardware.
Replace the input form with a canvas
The auto-generated input form is fine for a handful of scalars. When a workflow needs a purpose-built setup screen (a plate map, a picking grid, a summary the operator has to check), attach a canvas: a React component that renders in place of the standard form.
You attach one from the editor, which gives you a paste-and-save surface with a compile indicator and a live preview rather than a full code editor. The source is written into your project's canvas/ folder. Where those controls are is covered in The Workflow Editor.
From the CLI, zeon new canvas my_workflow writes canvas/my_workflow_screen.tsx and patches the workflow's canvas_ui block to point at it.
Your component runs sandboxed with no network access and can import only react and react-dom. It receives the workflow's input schema, the objects in the loaded world, and any default values, and it hands values back by calling a submit function. Every submission is validated against the input schema before the values are accepted, and validation errors are reported back in the canvas.
Two things to know before you run a canvas workflow:
- The canvas gates the run. Until the canvas has submitted a clean set of values, the workflow will not start. Editing values after confirming disarms it again, so confirm once more.
- A broken canvas degrades gracefully. If the source is missing, fails to compile, or throws, the app says so and offers to put the generated form back, so a bad canvas never blocks a run.
The workflow references the canvas through a canvas_ui block:
"canvas_ui": {
"kind": "react",
"source_ref": "canvas/my_workflow_screen.tsx",
"enabled": true,
"version": 2,
"updated_at": "2026-05-16T00:00:00.000Z"
}source_ref must match canvas/<canvas_id>.tsx, lowercase with underscores. version is bumped on every source save to bust the compiled-component cache. Set enabled to false and the workflow falls back to the generated form without you deleting the canvas. Whatever the canvas emits must conform to the workflow's own inputs. See Creating a canvas.
Reference: the file on disk
Workflows are plain JSON. You will usually edit them through the editor, but the format is what code review sees and what zeon sync moves. Full field detail is in workflows/<id>.json.
A newly scaffolded workflow is exactly this:
{
"workflow_id": "my_workflow",
"name": "my_workflow",
"description": "",
"version": "1.0.0",
"author": "",
"created_at": "2026-05-24T12:00:00.000Z",
"updated_at": "2026-05-24T12:00:00.000Z",
"simulation_validated": false,
"objects": [],
"inputs": [],
"nodes": [
{ "node_id": "start", "type": "start", "label": "Start" },
{ "node_id": "end", "type": "end", "label": "End" }
],
"edges": [
{
"edge_id": "e0",
"from_node": "start",
"to_node": "end",
"condition": { "type": "default" }
}
]
}What the gateway enforces
A save is rejected if any of these fail:
| Rule | Message |
|---|---|
workflow_id present, lowercase alphanumeric with underscores | workflow_id must be lowercase alphanumeric with underscores |
name present | name is required |
version matches MAJOR.MINOR.PATCH | version must follow semantic versioning (e.g., 1.0.0) |
| At least two nodes | At least 2 nodes (start and end) are required |
At least one start and one end node | At least one start node is required |
| At least one edge | At least 1 edge is required |
Every edge's from_node and to_node names a real node | Edge references non-existent from_node: … |
Note what is not checked: node types, edge condition types, and whether the graph is connected. A typo in a type field passes validation and fails later, at execution.
Nodes
Node type is one of start, end, skill, loop, conditional. Every node carries node_id, type, label, and an optional description. Skill nodes add skill_id, parameters, and an optional retry, an integer count of retry attempts on failure that overrides the default.
{
"node_id": "dispense_water_5",
"type": "skill",
"label": "Dispense Water to Wells",
"description": "Fresh tip per well; dispenses water into all wells requiring it",
"skill_id": "dispense_water",
"parameters": {
"target_plate": { "$input": "target_plate" },
"water_source": { "$input": "water_source" },
"volume_ul": 50
}
}
loopandconditionalare part of the file format and the type union, but the app creates only Start, Skill and End nodes, so they cannot be added from the editor today. Aloopnode takes aloopblock (typeofcount,collectionorconditional, plusiterations,sourceorexpression); aconditionalnode takes aconditionblock with anexpression. Treat them as a format capability, not a documented editor feature, until they appear in the palette.
Edges
Every edge is { edge_id, from_node, to_node, condition }. The condition.type values the executor understands:
| Condition | Fires when |
|---|---|
default | Unconditionally |
on_success | The source node completed successfully |
on_failure | The source node failed |
loop_continue | A loop has another iteration to run |
loop_complete | A loop has finished iterating |
if_true | A conditional evaluated true |
if_false | A conditional evaluated false |
A linear protocol uses default out of Start and on_success for every step after it:
"edges": [
{"edge_id": "e0", "from_node": "start_0", "to_node": "pick_pipette_1", "condition": {"type": "default"}},
{"edge_id": "e1", "from_node": "pick_pipette_1", "to_node": "aspirate_2", "condition": {"type": "on_success"}},
{"edge_id": "e2", "from_node": "aspirate_2", "to_node": "dispense_3", "condition": {"type": "on_success"}}
]Inputs
An input is { name, type, is_array, description?, defaultValue?, itemSchema? }.
Field names are accepted in both spellings: is_array or isArray, default_value or defaultValue, item_schema or itemSchema. The editor reads either and normalises to camelCase in memory. Do not rely on the spelling round-tripping unchanged.
"inputs": [
{ "name": "target_plate", "type": "object", "is_array": false, "description": "PCR wellplate" },
{ "name": "well_anchor", "type": "string", "description": "Well to dispense into", "defaultValue": "A1" },
{ "name": "volume_ul", "type": "float", "description": "Volume to dispense, in microlitres", "defaultValue": 5 }
]The two reference forms
A parameter value is either a literal or a reference, and there are two reference forms.
$input binds to a workflow input. At execution the runtime walks the parameter map, and for any value that is an object containing $input it substitutes the operator-supplied value for that input name. An $input reference may also carry an offset, which the editor preserves when you switch a parameter between sources. This is the current form and the one the editor writes.
{ "alias": … } names an entry in the workflow's top-level objects array:
"objects": [
{ "alias": "target_plate", "mesh_type": "wellplate_pcr", "display_name": "PCR Wellplate" }
],
"nodes": [
{
"node_id": "dispense_3",
"type": "skill",
"skill_id": "dispense",
"parameters": {
"target_plate": { "alias": "target_plate" }
}
}
]Each objects entry has an alias (the internal name that parameters reference), a mesh_type from the mesh database, and a display_name shown when mapping. Declaring objects by type rather than by world ID is what makes a workflow portable: the same graph runs against any world that can supply a wellplate_pcr.
Resolution is a separate step from substitution. Before a run, an alias is bound to a concrete world object, producing an object-bindings map. At execution the runtime replaces each aliased parameter's target with the bound world object ID. An alias with no binding is left as-is and logged as a warning. The run proceeds, and the skill receives an unresolved reference.
objectsis marked legacy in both the frontend types and the gateway model. It is still read and still resolved, but new work should declare an input of typeobjectand reference it with$input.
Updated 2 days ago