Skip to content

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"
}
}
PropertyRequiredDescription
idYesConvention: should be “start”
typeYesMust be "start"
connections.defaultYesNext 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"]
}
PropertyRequiredDescription
idYesConvention: should be “end”
typeYesMust be "end"
finalOutputNoContext 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"
}
}
PropertyRequiredDescription
directiveYesWhat the agent should do
completionConditionYesWhen the step is complete
inputSchemaNoJSON Schema for response validation
inputSchema.globalInputsNoNames of variableRegistry globals this node writes
inputSchema.propertiesNoNode-local outputs (referenced as node-id.name)
expressionsNoArithmetic expressions evaluated after the answer is validated
casesNoOrdered routing cases evaluated on the node’s validated answer
connections.successYesDefault output, taken when no case holds
connections.errorNoControl output taken when an expression on the node fails
connections.timeoutNoReserved control output; a case may not name it
humanGateNoMarks 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" }
}
PropertyRequiredDescription
humanGate.labelNoWhat the person is asked to do, in a few words; the step’s progress label otherwise
humanGate.whenNoA structured condition; the step waits for a person only when it holds as the run arrives
humanGate.notifyNoauto (default): the engine notifies the person when the run starts waiting; off: the flow sends the first message itself
humanGate.remindAfterNoHow 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"
}
}
PropertyRequiredDescription
casesYesOrdered { when, output } entries; at least one
cases[].whenYesStructured condition object
cases[].outputYesConnection key this case selects
expressionsNoArithmetic expressions evaluated before the cases
connections.defaultYesOutput taken when no case holds
connections.errorNoControl 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, neq
  • gt, gte, lt, lte
  • contains
  • exists
  • and, 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"
}
}
PropertyRequiredDescription
expressionsYesArray of assignment expressions
connections.defaultYesNext node after successful evaluation
connections.errorNoNext 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"
}
}
PropertyRequiredDescription
graphIdYesReferenced workflow ID
inputMappingYesParent context -> subgraph context
outputMappingYesSubgraph context -> parent context
connections.successYesNext 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"
}
}
PropertyRequiredDescription
messageYesNotification text with template support
formatNoPortable format: plain, markdown, or html
silentNoRequest silent delivery where the provider supports it
attachProgressImageNoAttach the run’s steps as a phone-width PNG
planListNoPlan lines: progress (default), full, or none
attachmentNoOne inline base64 image or document with name and MIME
connections.defaultYesFull, partial, or no-eligible-channel continuation
connections.errorNoTotal 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/total and 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" }
}
PropertyRequiredDescription
hintYesHuman-readable description of when to use teleport
directiveYesInstruction shown to agent after teleport
completionConditionYesSuccess criteria for the teleport step
inputSchemaNoJSON Schema for agent response validation
connections.successYesNext node after teleport input provided
connections.errorNoError 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 input when 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"
}
}
PropertyRequiredDescription
reasonYesLock reason (supports {{variable}} templates)
connections.unlockedYesNext node after lock is unlocked

Behavior:

  1. Starting any workflow that contains a lock node requires a valid Telegram bot token and chat ID for the current user. Neither skipNotificationCheck nor its deprecated skipTelegramCheck alias can bypass this requirement.
  2. 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.
  3. Missing or invalid settings and delivery failures create no usable active lock or context reference. Revisiting the node retries with a fresh delivery attempt.
  4. Subsequent visits check the active lock or validate a user-supplied PIN, then route through connections.unlocked after 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"
}
}
PropertyRequiredDescription
basePathYesRendered destination must be non-empty and contain no NUL character
filesYesOne or more archive entries
files[].pathYesTemplated safe path relative to basePath
files[].fromOne ofvariableRegistry string entry whose current default is the source
files[].contentOne ofMust be exactly ""; creates a skeleton file
connections.successYesNext node after empty completion input
connections.errorNoRoute 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:

Terminal window
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"
}
}
PropertyRequiredDescription
outputVariableYesContext variable to store results
filter.tagNoFilter by exact tag
filter.keyPatternNoFilter by key prefix
filter.keySearchNoSearch in key (contains)
singleModeNoReturn object instead of array
connections.errorNoError 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"
}
}
PropertyRequiredDescription
keyNo*Note key (required in single mode)
sourceYesContext variable with value
tagsNoTags to assign
batchModeNoProcess 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"
}
}
PropertyRequiredDescription
search.tagNoSearch by tag
search.keyPatternNoSearch by key prefix
keyTemplateYesKey for new note if not found
valueYesContext variable with note value

All filter and key parameters support {{ variable }} template expressions.

Best Practices

  1. Start with start - Every workflow needs exactly one start node
  2. End with end - Use end nodes to mark completion points
  3. Clear Directives - Be specific about what the agent should do
  4. Verifiable Conditions - Completion conditions should be objectively measurable
  5. Schema Validation - Use inputSchema for structured responses
  6. Error Paths - Use an error connection only where that node type’s documented runtime behavior routes it