Workflows
The portable definition of a Workflow graph.
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: respondA 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 Studio | In the definition |
|---|---|
| An existing Agent | agent: agents.<name> |
| A Team step | team: teams.<name> |
| A Tool | tools.<name> |
| A Knowledge Base | knowledgeBases.<name> |
| An LLM Configuration | an llm Slot id |
| A Data Table | table: 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.
| Group | type | Node | How the panel behaves | What the file holds |
|---|---|---|---|---|
| Triggers | trigger_manual | Manual trigger | Node page | Fields and example |
| Triggers | trigger_recurrence | Recurrence trigger | Node page | Fields and example |
| Triggers | trigger_http | Webhook trigger | Node page | Fields and example |
| Triggers | trigger_form | Form trigger | Node page | Fields and example |
| Triggers | trigger_email | Email trigger | Node page | Fields and example |
| Triggers | trigger_knowledge_document | Knowledge Base document trigger | Node page | Fields and example |
| Triggers | trigger_app | App trigger | Node page | Fields and example |
| AI | ai_agent | AI Agent | Node page | Fields and example |
| AI | team_step | Team step | Node page | Fields and example |
| AI | ai_prompt | AI Prompt | Node page | Fields and example |
| AI | classify | Classify | Node page | Fields and example |
| Control flow | if_else | If/Else | Node page | Fields and example |
| Control flow | loop | Loop | Node page | Fields and example |
| Control flow | wait | Wait | Node page | Fields and example |
| Control flow | human_review | Human Review (single or batch) | Node page | Fields and example |
| Data and files | variable | Variable | Node page | Fields and example |
| Data and files | edit_fields | Edit Fields | Node page | Fields and example |
| Data and files | convert_to_file | Convert to File | Node page | Fields and example |
| Data and files | data_table | Data Table | Node page | Fields and example |
| Data and files | knowledge_search | Knowledge Search | Node page | Fields and example |
| Integrations | send_email | Send Email | Node page | Fields and example |
| Integrations | http_request | HTTP Request | Node page | Fields and example |
| Integrations | mcp_tool | MCP Tool | Node page | Fields and example |
| Integrations | ftp | FTP | Node page | Fields and example |
| Integrations | app | App (Google Calendar, Contacts, Sheets, Drive, Gmail) | Node page | Fields and example |
| Utilities | code | Code | Node page | Fields and example |
| Utilities | note | Note | Node page | Fields 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, neveroutput.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 sobuild) checks onlycode,ai_agent,human_reviewanddata_tablenodes: it refuses a reference whose first segment afteroutputis not one of the node's fields below (WORKFLOW_UNKNOWN_OUTPUT_FIELD). The usual case is a Code node's key read withoutresult:{{ 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 onlyiterationsandloopNodeId, andvalidatechecks that too. A node inside a nested Loop's body, read from the enclosing Loop's body (outside its own), has no output yet:validaterefuses 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 type | Output fields | Example expression | Common 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_agent | text, 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_step | text, teamId, runId, sessionId, tokenUsage.prompt, tokenUsage.completion, latencyMs | {{ upstream.<id>.output.text }} | output.answer. |
ai_prompt | completionText, 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. |
classify | inputText, 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_else | branchTaken | {{ upstream.<id>.output.branchTaken }} | Reading the data the If/Else compared through it. It passes nothing on: read the node before it. |
loop | A 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. |
wait | mode, resumeAt, timezone, waitedMs, alreadyPassed, message | {{ upstream.<id>.output.resumeAt }} | |
human_review, mode: single | outcome, 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: batch | outcome, 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. |
variable | value | {{ upstream.<id>.output.value }} | Prefer {{ variables.<name> }}. |
edit_fields | One 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_file | file (filename, contentType, sizeBytes, text, fileId) | {{ upstream.<id>.output.file }} | |
data_table | resource, 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_search | documents (each documentId, documentName, source, metadata, score, excerpt, file), count | {{ upstream.<id>.output.documents.0.excerpt }} | output.results. |
send_email | messageId, accepted, rejected, and sent (subject, body) only when a policy redacted the message | {{ upstream.<id>.output.messageId }} | |
http_request | status, 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_tool | structured, text, files, content, toolName, serverId, serverName, isError, errorMessage | {{ upstream.<id>.output.structured.<field> }} | output.result. |
ftp | protocol, 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. |
app | app, resource, operation, status, result, itemCount, pages, truncated, file | {{ upstream.<id>.output.result }} | Reading the answer's fields without result. |
code | result (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 bybuild, 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_LEAKfor 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.