Elizon Docs
Features & SpecsPortable definitions

Workflows

The portable definition of a Workflow graph.

workflows/handle-request.yaml
name: Handle Request
description: Takes a request and answers it.
graph:
  nodes:
    - id: start
      type: trigger_manual
      position: { x: 0, y: 0 }
      data:
        description: An operator starts this workflow from the web app.
    - id: respond
      type: ai_agent
      position: { x: 280, y: 0 }
      data:
        mode: existing
        agent: agents.responder
        agentName: Responder
        inputMappings: []
  edges:
    - id: start-to-respond
      source: start
      target: respond

A Workflow is a graph: nodes (each with an id, a type, a canvas position and type-specific data) and edges connecting them (source → target). It's the same graph the Workflow canvas in Studio edits.

References inside nodes

Wherever Studio stores the id of an Agent, Team, Tool, Knowledge Base or model, the portable form uses a manifest-local reference or a Slot:

In StudioIn the definition
An existing Agentagent: agents.<name>
A Team stepteam: teams.<name>
A Tooltools.<name>
A Knowledge BaseknowledgeBases.<name>
An LLM Configurationan llm Slot id
A Data Tabletable: dataTables.<name>
An MCP server (MCP Tool node)server: tools.<name>
A connected account (App node, App trigger)connection: a credential Slot id

Node types

A Workflow can carry all 27 node types of the Studio canvas. Each type below links to its page under Workflows, which describes the panel in Studio, and to its section of Workflow nodes, which lists every field of its data, what travels and what validate and build refuse, with a working example. npx @elizon/cli example workflow --node <type> prints that example inside a complete project.

GrouptypeNodeHow the panel behavesWhat the file holds
Triggerstrigger_manualManual triggerNode pageFields and example
Triggerstrigger_recurrenceRecurrence triggerNode pageFields and example
Triggerstrigger_httpWebhook triggerNode pageFields and example
Triggerstrigger_formForm triggerNode pageFields and example
Triggerstrigger_emailEmail triggerNode pageFields and example
Triggerstrigger_knowledge_documentKnowledge Base document triggerNode pageFields and example
Triggerstrigger_appApp triggerNode pageFields and example
AIai_agentAI AgentNode pageFields and example
AIteam_stepTeam stepNode pageFields and example
AIai_promptAI PromptNode pageFields and example
AIclassifyClassifyNode pageFields and example
Control flowif_elseIf/ElseNode pageFields and example
Control flowloopLoopNode pageFields and example
Control flowwaitWaitNode pageFields and example
Control flowhuman_reviewHuman Review (single or batch)Node pageFields and example
Data and filesvariableVariableNode pageFields and example
Data and filesedit_fieldsEdit FieldsNode pageFields and example
Data and filesconvert_to_fileConvert to FileNode pageFields and example
Data and filesdata_tableData TableNode pageFields and example
Data and filesknowledge_searchKnowledge SearchNode pageFields and example
Integrationssend_emailSend EmailNode pageFields and example
Integrationshttp_requestHTTP RequestNode pageFields and example
Integrationsmcp_toolMCP ToolNode pageFields and example
IntegrationsftpFTPNode pageFields and example
IntegrationsappApp (Google Calendar, Contacts, Sheets, Drive, Gmail)Node pageFields and example
UtilitiescodeCodeNode pageFields and example
UtilitiesnoteNoteNode pageFields and example

A Code node's source travels as written and is scanned by validate and build (CODE_NODE_STATIC_SCAN_FLAGGED is a warning; the installing Organization re-scans). A node type that can't be packaged, such as a retired type surviving in an old saved version, is refused (PORTABLE_UNSUPPORTED_NODE_TYPE) with a hint to replace it in Studio. Edges between nodes, and the named handles of if_else, classify and loop, are described under Edges and handles.

Reading another node's output

A node reads an earlier node's output as {{ upstream.<id>.output.<path> }}, where <id> is that node's id in the graph. These rules cover every node type:

  • Every reference goes through .output. {{ upstream.<id>.text }} does not exist; {{ upstream.<id>.output.text }} does.
  • A path is dots only. A list entry is its index as a segment: output.files.0, never output.files[0].
  • A reference to a field the output does not have fails the node that reads it, with a message naming the path. It never resolves to an empty value.
  • Except in the nested-Loop case at the end of this item, validate (and so build) checks only code, ai_agent, human_review and data_table nodes: it refuses a reference whose first segment after output is not one of the node's fields below (WORKFLOW_UNKNOWN_OUTPUT_FIELD). The usual case is a Code node's key read without result: {{ upstream.<id>.output.rows }} instead of {{ upstream.<id>.output.result.rows }}. Read after the Loop (outside its body), a Loop-body node's output is only iterations and loopNodeId, and validate checks that too. A node inside a nested Loop's body, read from the enclosing Loop's body (outside its own), has no output yet: validate refuses every {{ upstream.<id>.output… }} reference to it there, for any node type and for the bare {{ upstream.<id>.output }} too.

The fields each node type outputs, as the run records them:

Node typeOutput fieldsExample expressionCommon mistake
Any trigger (Triggers)The payload that started the run, as the trigger delivered it. Its fields depend on the trigger and the workflow.{{ upstream.<id>.output.body.orderId }} (a Webhook trigger)Prefer {{ trigger.<path> }}: the same value, without naming the trigger node.
ai_agenttext, json, model, toolCalls, knowledgeQueriesCount, tokenUsage.prompt, tokenUsage.completion, estimatedCostUsd, latencyMs{{ upstream.<id>.output.text }}output.answer or output.content. output.json.<field> exists only when the answer text is a JSON object; otherwise json is null.
team_steptext, teamId, runId, sessionId, tokenUsage.prompt, tokenUsage.completion, latencyMs{{ upstream.<id>.output.text }}output.answer.
ai_promptcompletionText, structuredOutput (Structured and Custom formats only), modelUsed, tokenUsage.prompt, tokenUsage.completion, latencyMs, promptSent, estimatedCostUsd (when the model has a price){{ upstream.<id>.output.structuredOutput.category }}output.text (it is completionText), or output.category without structuredOutput.
classifyinputText, assignedCategories, discarded, modelUsed, tokenUsage.prompt, tokenUsage.completion, latencyMs, autoFixApplied{{ upstream.<id>.output.assignedCategories.0 }}output.category. Wire the category handles instead of branching on the output.
if_elsebranchTaken{{ upstream.<id>.output.branchTaken }}Reading the data the If/Else compared through it. It passes nothing on: read the node before it.
loopA list, one entry per iteration. Each entry is an object keyed by body node id; a Loop with an empty body records { index, item }.{{ upstream.<id>.output.0.<body node id> }}output.items. Usually you want a body node's own results (next row).
A node in a Loop body, read after the loop (Loop)iterations (that node's output, one entry per iteration it ran in, in order), loopNodeId{{ upstream.<id>.output.iterations }}output.0 or output.text: after the loop, output is { iterations, loopNodeId }. Inside the body, a later body node reads the current iteration's output as usual.
waitmode, resumeAt, timezone, waitedMs, alreadyPassed, message{{ upstream.<id>.output.resumeAt }}
human_review, mode: singleoutcome, decision, resolvedBy, requestId, response.<field>, responderEmail, respondedAt, timeoutAt, droppedApprovers{{ upstream.<id>.output.response.body }}output.body (the values are under response). Who answered is responderEmail, not reviewer or decidedBy; it is null when a timeout decided.
human_review, mode: batchoutcome, decision, resolvedBy, approved (each { index, item, response }), rejected and undecided (each { index, item }), counts.approved, counts.rejected, counts.undecided, requestId, responderEmail, respondedAt, timeoutAt, droppedApprovers{{ upstream.<id>.output.approved }} (feed it to a Loop)output.response (a batch has none). Inside a Loop over approved, an edited value is {{ loop.item.response.<field> }}. An empty item list outputs outcome: empty with empty lists and no responderEmail.
variablevalue{{ upstream.<id>.output.value }}Prefer {{ variables.<name> }}.
edit_fieldsOne key per row, named by the row's name. Flat: nothing wraps them.{{ upstream.<id>.output.<row name> }}output.rows.<name> or output.fields.<name>.
convert_to_filefile (filename, contentType, sizeBytes, text, fileId){{ upstream.<id>.output.file }}
data_tableresource, operation, table.id, table.name (table is null for List tables), affected, dryRun, result (the operation's own output){{ upstream.<id>.output.result.rows }}output.rows: the rows are under result.
knowledge_searchdocuments (each documentId, documentName, source, metadata, score, excerpt, file), count{{ upstream.<id>.output.documents.0.excerpt }}output.results.
send_emailmessageId, accepted, rejected, and sent (subject, body) only when a policy redacted the message{{ upstream.<id>.output.messageId }}
http_requeststatus, statusText, headers, body, file, request.method, request.url, request.headers, attempts, redirects, errorSuppressed. With fullResponse: false, the output is the body alone.{{ upstream.<id>.output.body.id }}output.data. With fullResponse: false, a body field is output.<field>, not output.body.<field>.
mcp_toolstructured, text, files, content, toolName, serverId, serverName, isError, errorMessage{{ upstream.<id>.output.structured.<field> }}output.result.
ftpprotocol, host, operation, path, affectedPaths, and per operation entries and count (List), file and size (Download), size (Upload), oldPath and newPath (Move), deleted (Delete){{ upstream.<id>.output.entries.0.path }}output.entries[0].path: use .0.
appapp, resource, operation, status, result, itemCount, pages, truncated, file{{ upstream.<id>.output.result }}Reading the answer's fields without result.
coderesult (what main() returned), files, stdout, durationMs, memoryBytes{{ upstream.<id>.output.result.rows }}output.rows: a returned key is always under result.

A Note node never runs, so it has no output to read.

Values the installer supplies

Two values are never carried, and are collected at install:

  • the approvers of a human-review step (emails),
  • the username of a password-protected form trigger (never the password).

The wizard asks for them, and the CLI takes them as --approver.<node-id>=… and --form-user.<node-id>=…. Carried text (notes, prompts, messages) must not name people either: an email address in carried text, such as a sticky note's ask jane@acme.com, fails validate and build with PORTABLE_PERSONAL_DATA_LEAK at the field's path (workflows.<name>.graph.nodes[<i>].data.text). A Code node's source (data.code on a code node) is the one exception: an address-shaped match there, such as x@self.weights or logo@2x.png, is a PORTABLE_MACHINE_CONTENT_EMAIL warning at workflows.<name>.graph.nodes[<i>].data.code and the Package still builds; a person's address in the source must still go. The node's inputExpression is refused as usual. A {{secret.…}} reference anywhere in the file, the source included, fails with PORTABLE_ORG_IDENTIFIER_LEAK. Only the people fields below, and a human-review fallbackAssignedTo (always stripped), are stripped instead. See Carried text.

People fields

A few node fields name who an email goes to: a human-review step's assignedTo, and a Send Email step's to, cc and bcc. One rule covers all of them, and build, validate and install apply the same check:

  • A field made only of {{ }} expressions travels as written: it names nobody until a run resolves it. For example {{ loop.item.Contact email }}. Separate several with commas or semicolons; a run splits the resolved value on those, so expressions separated only by spaces resolve to one invalid address.
  • Anything else — a literal address, an expression next to a literal, an address inside the braces, a {{secret.…}} reference, or stray text — is stripped by build, which reports a warning naming the node and the field (HUMAN_REVIEW_APPROVER_STRIPPED, SEND_EMAIL_RECIPIENT_STRIPPED). Empty fields are dropped silently.
  • An artifact that carries a non-expression people field is refused at install with PORTABLE_PERSONAL_DATA_LEAK (PORTABLE_ORG_IDENTIFIER_LEAK for a secret reference).

A stripped approver is collected at install (above). A stripped Send Email recipient is set on the canvas after install. A Send Email node's SMTP server, login and sender never travel, even when they are expressions.

Workflows and triggers

When a Workflow is the Package's entry point, the manifest's triggers start it. schedule, webhook, email, app and knowledge-document triggers require a Workflow entry point. See Triggers.

A Workflow's graph can also hold several trigger nodes, as it can in Studio: any mix of types, and any number of Manual triggers. A run starts only from the trigger that fired. It can hold at most one Webhook, Form, Email, App and Recurrence trigger node each; validate and build refuse a second one with PORTABLE_DEFINITION_INVALID at that node, the same refusal an install gives. See Several triggers on one workflow.