Workflows
A workflow in Moira is a directed graph of nodes that defines a multi-step process for AI agents to execute.
Workflow Structure
Static execution progress
A workflow may declare an optional top-level progress graph for a concise user-facing view. A
workflow of more than one stage should declare it, and mark with humanGate every step where it
waits for a person’s decision: a run is shown to people through exactly what the workflow declares,
and a workflow without blocks is shown as a run with no progress to report. The Workflow Management
Flow applies this by default and asks for a recorded reason to leave progress out. Its
definition may include a template-enabled title, goal, bounded generic facts, and ordered nodes.
Nodes contain id, template-enabled label, optional structured plain-text content (summary,
details, outcome, and next), an optional list binding, and an optional static
connections.default used for drawing.
A block that works through a list declares list with the variable paths it reads: items (the
array), title (a path inside one item; a string item is its own title), current (the index in
progress, counted from indexBase — 1 by default, 0 when declared), done (the finished
count) and total. At least one of items, current and total is required, total defaults
to the length of items, and done defaults to current − indexBase. Every path’s root is a
declared global or a node id for a node-local output; validation rejects an unknown root and a
title without items. The binding names paths only — the engine holds no notion of what the
list contains.
The progress graph is the workflow’s process view: its nodes are blocks, and their array order
is the process order. When progress is present, every node of the primary graph — routing nodes
included — declares the block it belongs to through progressNodeId; every block carries a
description in content.summary; and every connection that leaves a block, or returns to an
earlier block or to its own block, carries a connectionLabels entry keyed like connections:
a plain label, or { "label": …, "cycle": { "cause": …, "exit": … } } for a return. Transitions,
loops and hub blocks are derived from the primary graph and never authored twice; the display-only
connections.default is ignored by that derivation. An outcome template ({{progress_*_outcome}})
sits on exactly one block, which owns a node that writes the variable. Validation reports every
violation as an error with a stable code — unowned-node, unknown-block, empty-block,
empty-description, unlabeled-edge, unexplained-cycle, outcome-duplicate,
outcome-unowned, unconnected-block — and moira-workflow <file> derive or GET /api/workflows/:id/process shows
the derived blocks with the same diagnostics.
A node that pauses the run (an agent-directive step or another pausing node type) may also
declare template-enabled progressActiveLabel. It replaces its block’s displayed label only while
that exact node is current, letting a workflow show truthful unit, iteration, validation, or repair
detail without changing the block’s stable label. It requires the node to belong to a block and
never affects routing or stored state.
The independent execution task identity supplies the task title; arbitrary notes stay separate
(see Execution task name). The same node may declare
progressActiveContent with the same structured fields. Its fields replace the matching content
of its block only while that exact node is current; omitted fields keep their stable base values. Nested
strings use the normal variable registry and template protection. Progress stores no presentation
history, so replacing revision-bound context replaces the next projection instead of retaining
stale values from an earlier plan.
Resolved text is checked against the same bounds after interpolation. Oversized runtime data fails
the projection explicitly rather than being silently truncated into a misleading summary.
Block status comes from the execution’s recorded route, never from block order. Every execution
records one visit per node it runs — the node, the connection it left through (teleport for a
jump), the variables it changed, and whether it paused — and session({ action: "progress" })
projects that route onto the process: the block of the last visit is active, or waiting when
the run pauses there; a visited block is done, or repeated with the number of passes through
its working steps; a block whose work never ran or that the run bypassed is skipped; the rest
are pending. Returning to an earlier block through repair, a loop, or replan makes it active
again and counts another pass. Nothing unvisited is ever reported done, and an execution created
before routes were recorded reports only its current block as active with routeRecorded: false.
The same projection lists the route with loop markers and every variable with its history;
values set from outside the flow appear as adjustments with their actor. Connections never route
execution.
Every visit records when it was entered and left, so each block also reports its timing — every
pass with its duration, the total over all passes and how long the pass in progress has lasted —
and, when the block binds a list, its list: the items with their titles, which are done, which
one is in progress and the time spent on each. A visit recorded without timestamps has no
duration rather than a zero one. The projection reports the version stamped on the run
(executionWorkflowVersion) beside the definition it projected, and the run view adds
statistics: how long a
pass, a whole run through a block and each list position typically take across the owner’s other
completed runs of that version — the median with quartiles and range — so a run can be read
against what is usual; one user’s runs are never another’s statistics. The projection also says
who a paused run waits for (waitingFor): user at a gate a person clears (a lock step’s
PIN) or on a step the workflow marks with humanGate whose condition held as the run arrived,
or while the agent’s own question (session await-user) is open, agent on any other paused step,
null when the run is not waiting. It also says what is asked of the person (waitingForUser):
{ source: "agent", question, options, since } for the agent’s question — which takes precedence —
or { source: "gate", label } for a marked step. A projection at a route cursor never carries the
agent’s question, which is not part of the route.
GET /api/workflows/:id/statistics?version= returns the same aggregate for any version over the
caller’s completed runs.
When progress is present, every primary node maps to an existing block, as described above.
Multiple primary nodes may map to one block. The currently active primary node is the focus target
for its block; other blocks focus their first mapped primary node in workflow order. Pending and
skipped blocks keep summary, details, and next guidance but hide outcome, so an older revision
or unit result cannot appear current during an engine-owned transition.
The engine renders the run’s progress picture as the run page’s map, rasterised: the same ported
cards (a title band with the block’s number, name, pass count and status word — waiting for you
only when a person must act, agent on the step while the agent is on it, completed,
repeated, skipped (the name struck through), pending —
toned by status; input ports naming the source block,
output ports named by the transition label, dashed return ports, a double port for self
transitions; a facts line with the time spent and, for a block bound to a list, done/total with
the current item, no time for a block that never ran and — for a counter the binding did not
resolve; a typically … line from the
version’s statistics over the owner’s other completed runs) and the same edges (forward, skip,
hub, return, self), laid out by the same layout the web map uses; light or dark, from the same
projection. Text wraps to the card’s width and every label stays inside its box; at a viewport
of 720 px or less the picture uses the phone type scale and the stacked layout, wider pictures
the rows layout when it fits and the stacked one otherwise.
Agents request a short-lived, revision-bound, single-use download URL through session progress-image-token; the binary does not pass through MCP. The token takes optional theme
(light|dark), viewportWidth (480–4096), view — cards (the default: the card keeps the block’s summary, details, outcome and next
text) or process (the compact card alone, the map as the run page shows it; a loop’s cause and
exit are on the run page’s ports, not in the image) — and hide / collapse: block ids or authored node ids (a node names its block)
left out of the image with their transitions collapsed onto the neighbours, or drawn as a
label-only chip. Unknown ids are refused when the token is minted. A user-notification node may set
attachProgressImage: true and use its normal message as the image caption. Such a node must belong to
an existing block. The picture a notification attaches is not this map but the run’s steps, made
for a phone: the task on top, then one row per block in process order with its number, title,
status and, for a block with a bound list, done/total: current item; it is 390 px wide, never
shrunk, and grows downwards for a long flow. Below the message come the bound list’s plan lines (see planList on the
node) and then who the run waits for after it — ⏳ agent on the step: <block> or
🙋 waiting for you: <block> when the node leads straight to a step that pauses (a lock gate is a
person’s, and so is a directive marked humanGate whose when holds; any other directive,
teleport, materialize or subgraph wait is the agent’s); the attached image
shows the same state. The deprecated telegram-notification compatibility node supports
the same attachment for existing provider-specific workflows.
The PNG is rendered and published by a live paired controller. Select it with device_id on
session({ action: "progress-image-token", executionId, device_id }); omission requires exactly
one eligible live controller. COMMUNICATION_OWNER_UNAVAILABLE is a coded MCP failure and HTTP
503 on mint/public read. Restore the paired connection rather than recreating a VM.
Ordinary progress reads and text notifications need no owner; an unavailable optional
notification image is omitted while text is attempted independently.
The one-use progress URL and the immutable owner publication have different lifetimes. Successful HTTP completion consumes the progress grant; rendering/stream failure releases its claim, and an error response finishing does not consume it. The publication supports repeated reads until its own expiry under current authority.
Cloud notifications send the graph/run descriptor to the owner; PNG rendering uses the shipped owner renderer and does not require project packages.
The flow page (see the Reading and editing a flow guide) shows the derived process of the definition itself and lets its owner edit it in place. On the run page (see the Reading a run guide) the same projection is shown as the map — the process as a diagram with its contents — with a block panel that drills into each block’s steps, timings and list and focuses the technical node graph, which is the page’s other view. The page and the PNG contain the same essential information. A workflow without progress shows the technical node graph and the variables panel instead.
Execution task name
A run’s task name is independent of its workflow name, current step and arbitrary execution note.
Establish it at the first evidence-bearing intake, before submitting that step. Read
session({ action: "execution_context", executionId }), then use the returned revisions:
session({ action: "update-task-title", executionId, taskTitle: "Import October orders", expectedRevision: current.revision, expectedTaskIdentityRevision: current.metadataRevisions.taskIdentity,});Here current is the execution-context result. The owner can name an active run (running,
including stored waiting executions); completed and stopped runs refuse changes. The title is
trimmed, nonempty, and limited to 500 JavaScript string code units. Unicode control and formatting
characters are rejected before trimming. Both the step revision and independent identity revision
must match; on a stale response reread authoritative context and reconcile the actual task.
The server stores taskIdentity: { title, changedAt, changeId }; its time and change token are
server-owned. Naming preserves the presented step attempt, question, variables and other metadata
revisions. An identical normalized name changes no revision, activity or events. A real rename
contributes activity. An authorized responsibility that changes actual task scope updates the name;
ordinary progress, reordering and implementation tactics retain it. Naming grants no scope authority.
Headings use explicit identity, then the resolved authored progress.title, then the run’s own
workflow name. Notes never supply headings and remain separately inspectable. Sent messages retain
their historical name; subsequent notifications resolve current identity at delivery.
session progress and GET /api/executions/:id/progress return source: "trace" for authored
progress. Without it they return source: "metadata" with task identity, workflow/version and
execution revision/status, without invented blocks, route, variables or statistics. Runs without
identity have taskIdentity: null and use the same fallback. PNG grants require authored progress
and bind the independent task identity as well as step/context revisions; a rename invalidates a
grant, including a same-clock rename back to the original text. Request a fresh grant after stale
redemption or when a grant lacks the identity binding.
Workflow definition
Every workflow consists of:
{ "id": "my-workflow", "metadata": { "name": "My Workflow", "version": "1.0.0", "description": "Description of what this workflow does" }, "variableRegistry": { "project_name": { "type": "string", "description": "Name of the project" } }, "nodes": [ // Array of node definitions ]}Metadata
| Field | Required | Description |
|---|---|---|
name | Yes | Human-readable workflow name |
version | Yes | Semantic version string |
description | Yes | What the workflow accomplishes |
schemaVersion | No | Integer definition-schema version; current value is 1 |
schemaVersion describes the shape of the definition itself, next to the semver version that
describes its content. A definition without it is version 0 and is upgraded automatically wherever
it enters the system — validation, upload through the API, MCP or the CLI, the bundled catalog, and
stored definitions read back — so you never migrate by hand. moira-workflow <file> migrate
rewrites a file in place when you want the upgraded shape on disk.
Variable Registry
variableRegistry declares the workflow’s global variables once — the single source of truth for each variable’s type and description. Each entry is keyed by variable name:
| Field | Required | Description |
|---|---|---|
type | Yes | string, number, boolean, object, or array |
description | Yes | What the variable holds |
default | No | Initial value seeded at workflow start |
Globals are referenced by bare name ({{project_name}}); a node writes a global by listing its name in inputSchema.globalInputs.
Nodes Array
Nodes are the steps in your workflow. Each node has an id and a type that determines its behavior.
The id is unique in the workflow and kebab-case — lower-case letters and digits separated by
hyphens, such as review-plan — because edges are addressed as <node>.<key> and a node’s outputs
as {{<node>.<name>}}.
Workflow Execution
When a workflow starts:
- Engine creates an execution instance with a unique
processId - Finds the start node (type:
start) - Returns the first directive and its server-issued Step attempt ID
- Agent executes and submits the result with that Process ID and Step attempt ID
- Engine atomically records the transition, exact response receipt, and next attempt
- Repeat until reaching an end node (type:
end)
Repeating the same attempt with the same input returns its stored response without advancing again. Each later paused presentation has a different attempt ID.
Execution Context
Each execution maintains a context object containing:
{ variables: Record<string, unknown>; // Globals at variables[name]; node-local outputs at variables[nodeId] nodeStates: Record<string, unknown>; // Per-node state executionId: string; // Unique execution ID workflowId: string; // Source workflow ID currentNodeId: string; // Current position}Global variables (declared in variableRegistry) live at variables[name] and resolve by bare name ({{name}}). A node’s outputs live at variables[nodeId] and resolve as {{node-id.name}}.
Variables persist across steps. Globals carry workflow-wide values; node outputs carry per-step results.
Node Types
Common built-in node types are shown below. This table is representative, not exhaustive; see Nodes for the built-in types and their current contracts. Installed extensions may add namespaced node types; their current title, origin, version, and configuration schema come from the installation’s node-type catalog.
| Type | Purpose |
|---|---|
start | Entry point for workflow execution |
end | Terminal node marking completion |
agent-directive | Agent task with directive and completion condition |
condition | Branch to one of several outputs by ordered cases |
expression | Compute values using arithmetic expressions |
subgraph | Delegate to another workflow |
user-notification | Notify through the current user’s configured channels |
telegram-notification | Deprecated Telegram-only compatibility node |
Connections
Nodes connect via the connections object that defines the flow. Each node type has specific connection types:
Agent Directive Node
{ "id": "analyze-task", "type": "agent-directive", "directive": "Analyze the task requirements", "completionCondition": "Analysis is complete", "connections": { "success": "next-step", "error": "error-handler" }}Condition Node
{ "id": "check-status", "type": "condition", "cases": [ { "when": { "operator": "eq", "left": { "contextPath": "status" }, "right": "success" }, "output": "passed" } ], "connections": { "passed": "success-path", "default": "retry-path" }}Complete Workflow Example
{ "id": "simple-workflow", "metadata": { "name": "Simple Task Workflow", "version": "1.0.0", "description": "A basic workflow demonstrating node connections" }, "nodes": [ { "id": "start", "type": "start", "connections": { "default": "main-task" } }, { "id": "main-task", "type": "agent-directive", "directive": "Complete the assigned task", "completionCondition": "Task is completed successfully", "connections": { "success": "end" } }, { "id": "end", "type": "end" } ]}Workflow Visibility
Workflows have visibility settings:
- private - Only the owner can access
- public - All users can start the workflow
Best Practices
- Start with start - Every workflow must have a start node
- End with end - Use end nodes to mark completion
- Clear Directives - Write unambiguous instructions
- Measurable Conditions - Completion conditions should be verifiable
- Error Handling - Include error connections for graceful failures
- Documentation - Add descriptions to complex nodes