Skip to content

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

FieldRequiredDescription
nameYesHuman-readable workflow name
versionYesSemantic version string
descriptionYesWhat the workflow accomplishes
schemaVersionNoInteger 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:

FieldRequiredDescription
typeYesstring, number, boolean, object, or array
descriptionYesWhat the variable holds
defaultNoInitial 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:

  1. Engine creates an execution instance with a unique processId
  2. Finds the start node (type: start)
  3. Returns the first directive and its server-issued Step attempt ID
  4. Agent executes and submits the result with that Process ID and Step attempt ID
  5. Engine atomically records the transition, exact response receipt, and next attempt
  6. 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.

TypePurpose
startEntry point for workflow execution
endTerminal node marking completion
agent-directiveAgent task with directive and completion condition
conditionBranch to one of several outputs by ordered cases
expressionCompute values using arithmetic expressions
subgraphDelegate to another workflow
user-notificationNotify through the current user’s configured channels
telegram-notificationDeprecated 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

  1. Start with start - Every workflow must have a start node
  2. End with end - Use end nodes to mark completion
  3. Clear Directives - Write unambiguous instructions
  4. Measurable Conditions - Completion conditions should be verifiable
  5. Error Handling - Include error connections for graceful failures
  6. Documentation - Add descriptions to complex nodes
  • Nodes - Node types and configuration
  • Templates - Dynamic content in workflows