Nodes
Nodes are the building blocks of Moira workflows. Each node represents a step in the process with specific behavior defined by its type.
Node Types
The list below is Moira’s built-in set. An installation may also expose namespaced node types such
as extension-name.node-name from installed extensions. The live extension registry supplies their
configuration schemas to validation, while Moira’s node-type catalog describes them to the workflow
viewer.
Start
Entry point for workflow execution.
End
Terminal node marking workflow completion.
Agent Directive
Agent task with directive and completion condition.
Condition
Branch execution to one of several outputs by ordered cases.
Expression
Compute values using arithmetic expressions.
Subgraph
Delegate to another workflow.
User Notification
Send a notification through every enabled communication channel configured by the current user. Each message is headed by the workflow’s name and the run’s task, linked to the run page, and can carry the run’s plan.
Telegram Notification (deprecated)
Telegram-only compatibility node for existing provider-specific workflows. Its messages carry the
same heading, the progress plan line and the waiting line as user-notification.
Teleport
Jump target reachable only via explicit teleport.
Lock
PIN-based execution gate with Telegram approval. The PIN message opens with the same heading as a notification: the workflow’s name and the run’s task, with the run page on the next line.
Materialize
Deliver registry-backed files through a five-minute, node-bound tar grant.
Start Node
Entry point for workflow execution. Every workflow must have exactly one start node.
{ "id": "start", "type": "start", "connections": { "default": "first-task" }}| Property | Required | Description |
|---|---|---|
id | Yes | Convention: should be “start” |
type | Yes | Must be "start" |
connections.default | Yes | Next node ID |
The start node seeds the workflow’s global variables from the variableRegistry default values. Declare globals in the workflow-level variableRegistry, not on the start node.
End Node
Terminal node marking workflow completion. No outgoing connections.
{ "id": "end", "type": "end", "finalOutput": ["result", "summary"]}| Property | Required | Description |
|---|---|---|
id | Yes | Convention: should be “end” |
type | Yes | Must be "end" |
finalOutput | No | Context keys to include in final result |
note: End nodes have no connections - they are terminal nodes.
Agent Directive Node
The primary node type for agent tasks. Contains a directive (what to do) and completion condition (when done).
{ "id": "analyze-requirements", "type": "agent-directive", "directive": "Analyze the requirements document and identify key features", "completionCondition": "Features are listed with priorities", "inputSchema": { "type": "object", "globalInputs": ["analysis_done"], "properties": { "features": { "type": "array", "items": { "type": "string" } } }, "required": ["analysis_done", "features"] }, "connections": { "success": "next-step" }}| Property | Required | Description |
|---|---|---|
directive | Yes | What the agent should do |
completionCondition | Yes | When the step is complete |
inputSchema | No | JSON Schema for response validation |
inputSchema.globalInputs | No | Names of variableRegistry globals this node writes |
inputSchema.properties | No | Node-local outputs (referenced as node-id.name) |
expressions | No | Arithmetic expressions evaluated after the answer is validated |
cases | No | Ordered routing cases evaluated on the node’s validated answer |
connections.success | Yes | Default output, taken when no case holds |
connections.error | No | Control output taken when an expression on the node fails |
connections.timeout | No | Reserved control output; a case may not name it |
humanGate | No | Marks the step as one where the run waits for a person |
In the example, analysis_done is a declared global (it must exist in variableRegistry) written by this node and readable elsewhere as {{analysis_done}}; features is a node-local output readable as {{analyze-requirements.features}}. A returned key that is neither a declared global nor a described local output is rejected.
Use inputSchema to ensure structured responses. globalInputs lists the globals the node
writes; properties describes its node-local outputs. The engine validates and routes the
response automatically.
If a submission does not match inputSchema, the engine logs the rejection and pauses again at
the same node. The corrective message describes the expected schema and errors without echoing the
rejected payload. The validation cycle has no per-node bound or redirect; model business retry and
escalation explicitly in the workflow graph.
Routing on the answer
An agent-directive node can decide where the run continues from its own validated answer. cases
holds ordered { "when": <structured condition>, "output": "<connection key>" } entries; the first
when that holds selects its output, and when none holds the node takes success. The conditions
read the context after the answer has been merged, so an answer field is readable by bare name
(review_outcome for a declared global) and by node-local path (review.review_outcome).
{ "id": "review", "type": "agent-directive", "directive": "Review the change", "completionCondition": "Verdict recorded", "inputSchema": { "type": "object", "globalInputs": ["review_outcome"], "required": ["review_outcome"] }, "cases": [ { "when": { "operator": "eq", "left": { "contextPath": "review_outcome" }, "right": "blocked" }, "output": "blocked" } ], "connections": { "success": "merge", "blocked": "fix-issues" }}error and timeout are reserved control outputs: a case may not name them, and they need no case.
Waiting for a person
Some steps are where the flow stops for a person’s decision: the agent presents a plan or a result
and waits for an answer in the chat. Mark such a step with humanGate, and while a run is paused on
it the run is shown as waiting for you instead of waiting for the agent — on the run page, in its
progress, and in the waiting line of a notification that leads to it. The directive, its input and
its routing are unchanged: the agent still presents the question and submits the answer.
{ "id": "approve-plan", "type": "agent-directive", "directive": "Present the plan and ask the person to approve it", "completionCondition": "The person's decision is recorded", "humanGate": { "label": "Approve the plan", "when": { "operator": "neq", "left": { "contextPath": "operating_mode" }, "right": "autonomous" } }, "connections": { "success": "execute" }}| Property | Required | Description |
|---|---|---|
humanGate.label | No | What the person is asked to do, in a few words; the step’s progress label otherwise |
humanGate.when | No | A structured condition; the step waits for a person only when it holds as the run arrives |
humanGate.notify | No | auto (default): the engine notifies the person when the run starts waiting; off: the flow sends the first message itself |
humanGate.remindAfter | No | How long an unanswered wait lasts before one reminder from the engine, with either notify: a number and m, h or d (30m, 4h, 1d) |
The condition uses the same syntax as a routing case’s when and is evaluated once, when the run
arrives at the step; without it the step always waits for a person. Use it for a step that a run in
autonomous mode also enters and decides by itself, so an unattended run is not shown as waiting for
someone. A step that waits for the person in either mode — an external blocker only the person can
clear, say — needs no condition. A step where the agent does long work first and asks only at the end
is better left unmarked, since the mark would show the run as waiting for you during that work; its
directive has the agent raise the question with session await-user when it asks.
Marking, unmarking or changing the gate of a step in a new version of the workflow re-decides it for
runs already paused on that step, and they continue with their current step attempt; a new version
that leaves the gate alone does not re-evaluate the condition.
When a run starts waiting at a marked step, the person is notified through their notification
channels (the same ones user-notification uses): the flow and task, what they are asked, and a link
to the run; the answer goes to the agent in the chat. The notification is sent once per wait — a
repeated step or a reconnecting agent does not send it again — and if the wait is still open after
remindAfter, exactly one reminder follows. Set notify: "off" when the flow already sends its own
message right before that step — typically because the message carries what the person decides on,
such as the plan or the reason for a blocker — so the person does not get two; the validator warns
(gate-notified-twice) about a notification node that leads straight into a step with
notify: "auto". With notify: "off" the flow’s message is the first one, and remindAfter still
brings the engine’s single reminder. The reminder is counted from when the run started waiting there,
or, for a run already paused on the step when a new version marked it, from that update. The run page
shows under its banner whether the notification or the reminder was sent, when and where, or that no
channel is set up; for a notify: "off" step it shows nothing until the reminder goes out.
The moira-workflow CLI sets the mark with update <node> --human-gate '<json>' and removes it with
--human-gate none.
Condition Node
Branch execution on ordered cases. Each case pairs a structured condition with the connection key it
selects; the first case whose when holds wins, and when none holds the node takes default. A
two-way decision is one case plus default:
{ "id": "check-result", "type": "condition", "cases": [ { "when": { "operator": "eq", "left": { "contextPath": "status" }, "right": "success" }, "output": "passed" } ], "connections": { "passed": "success-path", "default": "retry-step" }}Any number of outcomes is written the same way — one case per authored output:
{ "id": "triage", "type": "condition", "cases": [ { "when": { "operator": "eq", "left": { "contextPath": "size" }, "right": "small" }, "output": "fast" }, { "when": { "operator": "eq", "left": { "contextPath": "size" }, "right": "large" }, "output": "slow" } ], "connections": { "fast": "quick-path", "slow": "long-path", "default": "normal-path" }}| Property | Required | Description |
|---|---|---|
cases | Yes | Ordered { when, output } entries; at least one |
cases[].when | Yes | Structured condition object |
cases[].output | Yes | Connection key this case selects |
expressions | No | Arithmetic expressions evaluated before the cases |
connections.default | Yes | Output taken when no case holds |
connections.error | No | Control output taken when an expression on the node fails |
Every other key of connections is an authored output named by a case. error is reserved for
control flow: a case may not name it, and it needs no case of its own.
Expressions on routing nodes
Both condition and agent-directive accept expressions — the same sandboxed arithmetic
interpreter and registry validation as the standalone expression node.
Assignments must target declared globals. They run before the cases (on an agent-directive node,
after its answer is validated), so a case can read what they computed, and their assignments are
published only when the node succeeds. A failing expression takes connections.error when the node
has one; otherwise the node fails, exactly as a standalone expression node does.
{ "id": "count-attempt", "type": "condition", "expressions": ["attempts = attempts + 1"], "cases": [ { "when": { "operator": "gte", "left": { "contextPath": "attempts" }, "right": 3 }, "output": "exhausted" } ], "connections": { "exhausted": "escalate", "default": "retry-step", "error": "handle-error" }}Structured Conditions
Conditions use a structured format (not string evaluation):
{ "operator": "and", "conditions": [ { "operator": "gt", "left": { "contextPath": "score" }, "right": 80 }, { "operator": "eq", "left": { "contextPath": "validated" }, "right": true } ]}Supported operators:
eq,neqgt,gte,lt,ltecontainsexistsand,or,not
Expression Node
Compute values using arithmetic expressions. Useful for counters, calculations, and variable transformations:
{ "id": "increment-counter", "type": "expression", "expressions": ["counter = counter + 1", "result = counter * multiplier"], "connections": { "default": "next-step", "error": "error-handler" }}| Property | Required | Description |
|---|---|---|
expressions | Yes | Array of assignment expressions |
connections.default | Yes | Next node after successful evaluation |
connections.error | No | Next node on evaluation error |
Expression Syntax
Expressions support basic arithmetic operations:
- Arithmetic:
+,-,*,/ - Parentheses:
(a + b) * c - Assignment:
result = a + b - Context paths:
step.index,plan.items[0].value,tasks[current_index].action
{ "expressions": ["total = price * quantity", "tax = total * 0.1", "final_price = total + tax"]}Expression evaluation uses a custom sandboxed parser, NOT JavaScript eval. Member reads use own properties only; fixed or variable array indexes must resolve to in-bounds non-negative integers. Assignment targets are safe bare variable names. With a variable registry, assignments must name declared globals and satisfy their JSON Schemas; the node publishes nothing on error.
Error Handling
Expression nodes can fail in two cases:
- Division by zero
- Invalid, unsafe, unresolved, or out-of-bounds member read
- Undeclared assignment target or value outside its registry schema
When an error occurs, execution routes to the error connection if defined, otherwise the workflow fails.
Subgraph Node
Delegate execution to another workflow:
{ "id": "run-tests", "type": "subgraph", "graphId": "test-workflow", "inputMapping": { "codeDir": "projectPath" }, "outputMapping": { "testResults": "results" }, "connections": { "success": "next-step" }}| Property | Required | Description |
|---|---|---|
graphId | Yes | Referenced workflow ID |
inputMapping | Yes | Parent context -> subgraph context |
outputMapping | Yes | Subgraph context -> parent context |
connections.success | Yes | Next node on success |
Subgraphs enable workflow composition and reuse. The agent sees it as a continuous workflow.
connections.error is optional and is not the route for thrown child-execution or mapping
failures in the current runtime. Those failures are logged and execution pauses at the subgraph so
the agent can correct the cause and retry.
User Notification Node
Send through every valid enabled channel configured by the execution user. The workflow cannot choose a provider, recipient, or credential:
{ "id": "notify-complete", "type": "user-notification", "message": "🏁 *Completed* — {{deliver-result.result_summary}}", "planList": "full", "format": "markdown", "silent": false, "connections": { "default": "next-step", "error": "notification-failed" }}| Property | Required | Description |
|---|---|---|
message | Yes | Notification text with template support |
format | No | Portable format: plain, markdown, or html |
silent | No | Request silent delivery where the provider supports it |
attachProgressImage | No | Attach the run’s steps as a phone-width PNG |
planList | No | Plan lines: progress (default), full, or none |
attachment | No | One inline base64 image or document with name and MIME |
connections.default | Yes | Full, partial, or no-eligible-channel continuation |
connections.error | No | Total attempted failure; otherwise it also uses default |
Every delivered message opens with a heading — the workflow’s name and the run’s canonical task
name (execution task identity), linked to the run page — followed by message, the run’s plan,
and, when the node leads straight to a step the run pauses on, who is waited for there:
⏳ agent on the step: <block> or 🙋 waiting for you: <block> (a lock gate or a step marked
humanGate). Arbitrary notes do not supply the heading. Without explicit identity the resolved
authored progress title, then the run’s own workflow name supplies it. The plan comes from the bound list nearest the
run as of this node, including what the current step has just written; planList chooses how much
of it the message carries:
progress(the default) — one line,📝 done/total: current item;full—📝 done/totaland every item numbered with its state:✓done,▶in progress,○pending; a plan too long for the channel folds around the item in progress into… N earlier/… N more;none— no plan.
A notification whose default connection leads straight to an end node is the run’s last
message: no item is in progress any more, so an unfinished item reads ○ and the progress line
names no current item.
Before the run reaches the block that binds the plan, the first bound block whose items are
written is used, so a “plan ready” message can show the plan. With format: "markdown" or
"html", every value substituted into the message is escaped for that format, so a value cannot
add markup; the author’s own markup is kept, and a value placed inside the author’s link or code
markup shows its escapes, so keep values outside it. The text is fitted to the channel’s limit:
the plan takes the room left, and an over-long message is cut at a line with …. On Telegram,
link previews are off for every message.
An explicit attachment and an enabled attachProgressImage are mutually exclusive.
File handling and progress rendering run at the selected file owner: an enrolled workspace
service or a paired controller. Filenames must be safe basenames and MIME types must have a
valid type/subtype form. The owner retains an immutable source, the original delivery receipt
and per-channel stages; Telegram’s own image/document limits still apply.
An explicit attachment that cannot be delivered follows the node’s authored error/default
policy; text delivery cannot silently stand in for its success. For optional
attachProgressImage, unavailable rendering or an unavailable owner omits the picture and
still attempts the ordinary text notification independently. Text, waiting reminders and
trusted lock PIN delivery do not require a file owner.
The result exposes attachmentStatus, communicationRequestId and the observed owner identity
when available. Preserve them to collect an uncertain delivery through communication action
operation; do not rerun a workflow visit to resend it. A new visit is a distinct intent.
Collection cannot send again, and current execution and task-name bindings are rechecked
before vendor delivery.
The node result is stored under its node ID. Ordinary text reports userNotificationStatus as
delivered, partial, no_configured_channels, or all_failed, with counts and sanitized
per-channel status/reason values. Successful owner delivery reports delivered or
no_configured_channels; channels contains the original per-operation delivery stages.
Only configured channels supporting the requested file kind are selected. Results and errors
do not contain credentials, recipients, message bodies or attachment bytes.
Deprecated Telegram-specific node
telegram-notification remains executable for existing workflows. It sends only through Telegram,
and an authored chatId keeps its exact provider-specific recipient meaning. New ordinary
notifications should use user-notification; changing an explicit legacy recipient to generic
fan-out requires an intentional workflow migration.
Teleport Node
Jump target reachable only via explicit teleport, not via normal connections. Behaves like agent-directive (pauses for input, validates schema) but is only reachable when the agent explicitly requests a teleport jump.
{ "id": "teleport-replan", "type": "teleport", "directive": "Rewrite the development plan", "completionCondition": "New plan created and validated", "hint": "Use when current plan needs restructuring", "inputSchema": { "type": "object", "properties": { "reason": { "type": "string" } }, "required": ["reason"] }, "connections": { "success": "plan-node" }}| Property | Required | Description |
|---|---|---|
hint | Yes | Human-readable description of when to use teleport |
directive | Yes | Instruction shown to agent after teleport |
completionCondition | Yes | Success criteria for the teleport step |
inputSchema | No | JSON Schema for agent response validation |
connections.success | Yes | Next node after teleport input provided |
connections.error | No | Error handler node |
Teleport nodes must NOT have incoming connections from other nodes. They are excluded from unreachable node warnings during validation.
Using Teleport at Runtime
When a workflow contains teleport nodes, their hints are automatically appended to each step response under “Available Teleport Jumps”. To jump to a teleport node, use the teleportTo parameter in step():
step({ processId: "abc123", attemptId: "attempt-current", teleportTo: "teleport-replan" })- Only teleport-type nodes can be targets
- Use the Step attempt ID from the current presentation
- Do NOT provide
inputwhen teleporting — the teleport node will present its own directive - Execution context (all variables) is preserved across the teleport
- After providing input to the teleport node, execution continues via its
connections.success
Lock Node
PIN-based execution gate. It delivers the PIN to the current user’s configured Telegram chat with an Approve inline keyboard and pauses only after that delivery succeeds.
{ "type": "lock", "id": "approval-gate", "reason": "Deploy to production for {{workflow_name}}", "connections": { "unlocked": "proceed-node" }}| Property | Required | Description |
|---|---|---|
reason | Yes | Lock reason (supports {{variable}} templates) |
connections.unlocked | Yes | Next node after lock is unlocked |
Behavior:
- Starting any workflow that contains a lock node requires a valid Telegram bot token and chat ID for the current user. Neither
skipNotificationChecknor its deprecatedskipTelegramCheckalias can bypass this requirement. - On the first visit, Moira stores only a hashed pending PIN, sends the plaintext PIN to that configured chat, activates the lock, stores
_lockId, and pauses the execution. - Missing or invalid settings and delivery failures create no usable active lock or context reference. Revisiting the node retries with a fresh delivery attempt.
- Subsequent visits check the active lock or validate a user-supplied PIN, then route through
connections.unlockedafter resolution.
MCP and workflow responses never contain the generated PIN. The user can enter a PIN supplied out of band or approve the lock from Telegram.
Materialize Node
Delivers author-defined files to the agent filesystem, keeping their rendered contents out of the
agent context. The server creates a five-minute, node-bound tar grant; the agent runs or retries the
exact command in the generated directive and then completes the step with null or {}. A host that
cannot run that command uses the directive’s context-delivery fallback instead, which spends context
and completes the step the same way.
{ "id": "materialize-standards", "type": "materialize", "basePath": "{{workspace_path}}", "files": [ { "path": "standards/planning.md", "from": "planning_standards" }, { "path": "plans/.keep", "content": "" } ], "connections": { "success": "create-plan", "error": "materialize-failed" }}| Property | Required | Description |
|---|---|---|
basePath | Yes | Rendered destination must be non-empty and contain no NUL character |
files | Yes | One or more archive entries |
files[].path | Yes | Templated safe path relative to basePath |
files[].from | One of | variableRegistry string entry whose current default is the source |
files[].content | One of | Must be exactly ""; creates a skeleton file |
connections.success | Yes | Next node after empty completion input |
connections.error | No | Route for validation, configuration, or grant-issuance failure |
from and content are mutually exclusive. A from entry must name a declared string variable
with a string default; arbitrary inline text is rejected. When the step is presented, the server
renders basePath and the file-path summary shown in the directive. The rendered basePath is part
of the issued command. On the first archive request, cloud resolves current paths and text from registry defaults,
own Notes and Playbooks with the bound context. A paired controller encodes UTF-8, builds the tar
and retains its immutable publication. The grant pins that controller’s full identity and original
request ID before submission. Repeats collect the same receipt without rendering, selecting another
owner or submitting again; later definition edits do not alter the published bytes. Re-presenting
the step issues a new grant for a separate explicit attempt.
The generated POSIX command has this shape, with every argument already shell-quoted:
mkdir -p -- '<basePath>' && curl -sSf -- '<reusable-url>' | tar -x -C '<basePath>'Copy the emitted command exactly. Archive entries are relative to basePath; the tar never
contains basePath itself. Paths must be normalized, relative, non-empty, unique after template
rendering, and contain no empty, ., .., absolute, backslash-rooted, or NUL segments. Archive files have no generic count or byte ceiling; available owner storage and authority apply.
The URL expires after five minutes and is bound to the current user, execution, and node. It can be
downloaded repeatedly while the execution remains waiting on that node and is invalid immediately
after the execution advances. The generated directive explains this retry window and reminds the
agent that delivery does not prove reading; each later consumer must still explicitly require the
files it uses to be read. Calling session({ action: "current_step" }) while paused issues a fresh
URL without advancing the graph. Hosts unable to execute the command use session materialize
for the existing bounded context-delivery fallback. Recovery to this node re-presents its delivery
without advancing it; delivery resolves current registry defaults, while changing context alone
does not rewrite the caller’s files.
See Materialize Files for the complete archive, path, grant, error, and Workflow Management Flow contracts.
Extension nodes
Installed extensions contribute namespaced types such as corporate-messenger.send. Their
manifest supplies the configuration form and validation schemas; the browser renders those schemas
with its generic editor and never loads extension frontend code. A successful result is stored under
the node ID. Input, handler, timeout, runner and output failures follow connections.error when it
exists; otherwise Moira records the diagnostic and pauses on the node for retry.
The live node-type catalogue distinguishes an unavailable extension runner from a live registry in which one extension is not installed. See Writing an Extension for the manifest, SDK and execution contracts.
Input Schema
Define expected response structure using JSON Schema:
{ "inputSchema": { "type": "object", "properties": { "summary": { "type": "string", "description": "Brief summary of findings" }, "items": { "type": "array", "items": { "type": "object", "properties": { "name": { "type": "string" }, "priority": { "type": "number" } } } } }, "required": ["summary"] }}Automatic Node Types
Automatic nodes execute without agent interaction. They run server-side and immediately continue to the next node. Used for note storage operations.
Read Note Node
Reads notes matching filter criteria into context variable:
{ "type": "read-note", "id": "load-notes", "outputVariable": "projectNotes", "filter": { "tag": "{{projectTag}}", "keyPattern": "project-" }, "singleMode": false, "connections": { "default": "next-node", "error": "error-handler" }}| Property | Required | Description |
|---|---|---|
outputVariable | Yes | Context variable to store results |
filter.tag | No | Filter by exact tag |
filter.keyPattern | No | Filter by key prefix |
filter.keySearch | No | Search in key (contains) |
singleMode | No | Return object instead of array |
connections.error | No | Error handler node |
Write Note Node
Writes data from context to notes:
{ "type": "write-note", "id": "save-results", "key": "results-{{timestamp}}", "source": "analysisResults", "tags": ["analysis"], "connections": { "default": "next-node" }}| Property | Required | Description |
|---|---|---|
key | No* | Note key (required in single mode) |
source | Yes | Context variable with value |
tags | No | Tags to assign |
batchMode | No | Process array of notes |
Upsert Note Node
Find-or-create operation:
{ "type": "upsert-note", "id": "upsert-config", "search": { "tag": "config" }, "keyTemplate": "{{projectId}}-config", "value": "configData", "connections": { "default": "next-node" }}| Property | Required | Description |
|---|---|---|
search.tag | No | Search by tag |
search.keyPattern | No | Search by key prefix |
keyTemplate | Yes | Key for new note if not found |
value | Yes | Context variable with note value |
All filter and key parameters support {{ variable }} template expressions.
Best Practices
- Start with start - Every workflow needs exactly one start node
- End with end - Use end nodes to mark completion points
- Clear Directives - Be specific about what the agent should do
- Verifiable Conditions - Completion conditions should be objectively measurable
- Schema Validation - Use
inputSchemafor structured responses - Error Paths - Use an error connection only where that node type’s documented runtime behavior routes it