Task Node Mechanics
The universal anatomy of a task node and how data threads through one.
What it is
Every step you drop on the Visual Workflow Builder
canvas is a task node — the same JSON shape regardless of whether it classifies a
document, calls a REST endpoint, or sends an email. This page is the deep reference for that
shared shape: how a node is structured, how {{variable}} inputs resolve, how a raw task
result becomes named output variables, and how failures, retries, and async waits behave.
This page does not re-list the node catalog. For which nodes exist and their per-node config fields, see the full palette in Visual Workflow Builder and the Node catalog. Here we cover the mechanics those pages assume.
When to use it
Read this when you are wiring nodes together and need to know exactly how {{node.field}}
resolves, why a variable came through empty or as a literal {{...}}, how to reshape a
task's output, or what happens to the rest of the graph when one node fails. If you only need
to pick a node and fill in its form, the catalog is enough.
How it works
Process flow — the life of a single node, from inputs to context:
Call flow — how the executor reaches a processor and threads the result back. The DMS
transforms the frontend node format to the backend format, the Router enqueues the job, and a
typed processor returns a result that matches the node's output_schema (per
communication-standard):
Sub-features
- Universal node anatomy —
id,type,data.label,data.task_type,data.config, plus the workflow-levelinput_schema/output_schema. - Frontend vs. backend format — the canvas writes
type: "<task_type>"; the executor needstype: "task"with the real type indata.task_type. The DMS transforms it. - Input mapping —
{{variable}}templating across every string inconfig, resolved from upstream node outputs, trigger inputs, and built-in context vars. - Output mapping — JSONPath (
$.field) that lifts fields out of a raw result into named variables downstream nodes can reference. - Per-node error policy —
max_retries, failure propagation, decision-gate routing, and the wait/poll node for async deadlines.
Node anatomy — the common shape
Every task node is the same envelope. The bold fields below are present on all node types;
only data.config varies by task_type.
{
"id": "classify-1",
"type": "classification_task",
"position": { "x": 120, "y": 80 },
"data": {
"label": "Classify Document",
"task_type": "classification_task",
"config": {
"classification_type": "document_type",
"include_confidence": true
}
}
}The workflow as a whole also carries an input_schema (and usually an output_schema):
{
"nodes": [ /* ... */ ],
"edges": [ /* ... */ ],
"input_schema": { "type": "object", "properties": {}, "required": [] }
}Frontend vs. backend format (and the transform)
The canvas and the executor disagree on one field — type:
node.type | node.data.task_type | |
|---|---|---|
| Frontend (canvas) | "classification_task" (the real type) | "classification_task" |
| Backend (executor) | "task" (generic) | "classification_task" (the real type) |
The executor filters nodes by type === "task" and reads data.task_type to pick the
processor. The DMS applies the transform before enqueuing — conceptually:
nodes.map(node => ({ ...node, type: "task" }))
// data.task_type is left untouched as the real typeIf a node reaches the executor with type: "classification_task" instead of type: "task",
the processor silently skips it and logs "Executing workflow with 0 steps" — the run
completes with no outputs. This is almost always a hand-built or imported definition that
missed the transform.
Input mapping & data flow
Any string inside data.config may contain {{variable}} references. Before the processor
runs, the executor replaces each {{name}} with a value resolved from, in order:
- Upstream node outputs — fields the source node produced (and named via
output_mapping). - Trigger / workflow inputs — the
inputsobject passed at execution time (webhook payload, manual run form, schedule context). - Built-in context vars — e.g.
{{user_id}},{{execution_id}},{{workflow_id}}.
The substitution is literal string replacement: if a name does not resolve, the literal
{{name}} is left in place rather than becoming empty — a useful tell when debugging.
End-to-end: classify → decide → extract
Three nodes threaded together. Node 1 classifies, names its result fields with
output_mapping; node 2 (decision) routes on a classified field; node 3 extracts only on the
"Invoice" branch and references the same field.
{
"nodes": [
{
"id": "classify",
"type": "classification_task",
"data": {
"label": "Classify",
"task_type": "classification_task",
"config": {
"classification_type": "document_type",
"output_mapping": {
"doc_type": "$.classification.type",
"doc_confidence": "$.confidence"
}
}
}
},
{
"id": "is-invoice",
"type": "decision",
"data": {
"label": "Is Invoice?",
"task_type": "decision",
"config": {
"operator": "==",
"left_operand": "$.classify.doc_type",
"right_operand": "Invoice"
}
}
},
{
"id": "extract",
"type": "extraction_task",
"data": {
"label": "Extract Invoice",
"task_type": "extraction_task",
"config": {
"output_format": "json",
"prompt_text": "Extract line items for a {{doc_type}} document.",
"output_mapping": { "invoice_total": "$.extraction_result.total" }
}
}
}
],
"edges": [
{ "id": "e1", "source": "classify", "target": "is-invoice" },
{ "id": "e2", "source": "is-invoice", "target": "extract",
"sourceHandle": "true", "label": "true" }
]
}How the references resolve at runtime:
classifyruns, produces a raw result, andoutput_mappingliftsdoc_typeanddoc_confidenceout of it.- The decision node compares
$.classify.doc_type(JSONPath into the upstream node's named output) against the literal"Invoice". JSONPath operands here read from the named task outputs, not from the raw result. - On the
trueedge,extractruns; itsprompt_texttemplate{{doc_type}}resolves to"Invoice", and its ownoutput_mappingnamesinvoice_totalfor any node after it.
Array-vs-string pitfall. Fields declared as arrays in a node's schema (e.g. an email
to) must be arrays, even with one value: ["a@x.com"], never "a@x.com". A bare string is
treated character-by-character — the UI will report something like "17 recipients" and
delivery breaks. When a template like {{recipient}} feeds an array field, make sure the
upstream value is itself a list.
Per-node config & output_mapping
A processor returns a raw result shaped by the node's output_schema. output_mapping
(JSONPath, $. root) selects pieces of that raw result and promotes them to named
variables that downstream {{...}} templates and JSONPath operands can reach. Without an
output_mapping, downstream nodes must navigate the raw shape directly.
Example — an extraction node. Raw result:
{
"extraction_result": {
"name": "John Doe",
"email": "john@example.com",
"skills": ["Python", "ML"]
},
"confidence": 0.92,
"tokens_used": 1250
}Config with output_mapping:
{
"config": {
"output_format": "json",
"output_mapping": {
"candidate_name": "$.extraction_result.name",
"candidate_email": "$.extraction_result.email",
"match_confidence": "$.confidence"
}
}
}Now {{candidate_name}} resolves to "John Doe" in any downstream node, instead of forcing
that node to reach into $.extraction_result.name. JSONPath also indexes arrays
($.items[0].id) and nested objects ($.response.data.status).
Error handling, retries & timeouts
Failure handling lives partly on the node and partly in the graph:
max_retries— a node may be configured to retry its processor call before giving up. Use it for flaky network nodes (REST, integrations), not for deterministic transforms.- Failure propagation — when a node fails (and exhausts retries), the executor marks that
task_states[node_id]as"failed", sets the whole execution tostatus: "failed", records thefailed_nodeanderror, and stops — downstream nodes do not run. There is no implicit "continue on error". - Decision-gate routing on failure — to keep going after an expected error, route around it: have an upstream node emit a status field and a decision node send the failure case down a recovery branch rather than letting the node hard-fail.
- Async waits & deadline budget — for work that completes later (RAG indexing, an external
job), use a wait/poll node: it polls an endpoint's
watch_fielduntil it hitssuccess_value, fails onfailure_values, and gives up aftertimeoutseconds (the deadline budget). This keeps long-running steps from blocking indefinitely.
A failed execution surfaces like this:
{
"status": "failed",
"error": "REST API call failed: 404 Not Found",
"failed_node": "fetch-role-config",
"task_states": {
"fetch-role-config": { "status": "failed", "error": "404 Not Found" }
}
}Configuration parameters
These are the cross-cutting fields on every task node, regardless of type. Per-node config field schemas live in the Node catalog — link, don't duplicate.
| Field | Type | Meaning |
|---|---|---|
id | string | Unique node id within the workflow; the key under which task_states and outputs are stored. |
type | string | "task" at execution time (the generic type); the specific type on the canvas. |
data.label | string | Display name on the canvas; cosmetic. |
data.task_type | string | The real task type — selects the processor (e.g. classification_task). |
data.config | object | Per-node settings; every string value supports {{variable}} templates. |
data.config.output_mapping | object | JSONPath ($.…) map from the raw result to named output variables. |
data.config.max_retries | number | Retries before the node fails (use for network-bound nodes). |
input_schema | object | Workflow-level, required; declares the inputs a run accepts. Omitting it 400s the execution. |
output_schema | object | Workflow-level declaration of the final output shape. |
Dependencies
- DMS (
workflow-builderservice) — owns task-type definitions and performs the frontend→backend node transform before enqueuing. - Router + Redis —
workflow_queuecarries the job;results_queuereturnsstatus,outputs, andtask_states. - Typed processors — one per task family (extraction, classification, summarization,
decision, wait, rest_api, email…), selected by
data.task_type. - Upstream nodes & edges — a node's inputs are assembled from its incoming edges; an
edge's
sourceHandle(default"output") names the slice of the source result passed on.
Limitations
- A node's raw output shape is fixed by its
output_schema. Reshape withoutput_mapping, not by editing the node. - No continue-on-error. Any unhandled node failure fails the whole run; recovery must be modeled explicitly with decision branches.
- Unresolved
{{var}}stays literal. A missing variable is not an error — the literal{{var}}passes through, which can produce confusing downstream results. - Acyclic only. Decision nodes diverge and re-converge but the graph stays a DAG — no loops.
How to test
- Confirm the node type exists:
GET /api/workflows/task-typeslists it with itsconfig/input/outputschemas. - Drop the node and verify the config panel renders the fields from its
config_schema. - Run the node in isolation (node test modal /
test-llm-node) and compare the raw result to itsoutput_schema. - Verify the transform: the saved/exported definition has
type: "task"and the real type indata.task_typefor every node (else "0 steps"). - Verify input mapping: every
{{var}}in a node's config resolves — no literal{{...}}left in the rendered config or the result. - Verify output mapping: each
output_mappingkey appears as a named variable in the node's result; downstream{{key}}references resolve. - Verify array fields: multi-value fields (e.g. email
to) are JSON arrays, not strings. - Verify failure path: force an error (bad URL, wrong id) and confirm
status: "failed", the rightfailed_node, and that any intended decision-branch recovery fires. - Verify async: for wait/poll nodes, confirm success on the target value and a clean timeout failure when the deadline passes.
Tips & best practices
- Name outputs at the source. Add an
output_mappingto any node whose result is consumed later —{{candidate_name}}is clearer and more stable than$.extraction_result.name. - Map once, reference everywhere. Promote a field to a named variable in the producing node rather than re-deriving it in each consumer.
- Keep arrays arrays. When templating into array fields, ensure the source value is a list.
- Budget every async step. Always set a
timeout(and sensiblepoll_interval) on wait/poll nodes; never poll unbounded. - Model recovery, don't hope for it. Use decision gates for expected failures instead of relying on retries to mask them.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Variable renders as literal {{name}} | Name never resolved (typo, or upstream never produced it) | Check the producing node ran and that its output_mapping (or trigger input) defines name. |
| Variable resolves empty | Field exists but is null/absent in the raw result | Confirm the JSONPath in the upstream output_mapping points at the real path. |
| Workflow logs "0 steps" | Node type is the specific type, not "task" | Re-run through the DMS transform so type: "task" and data.task_type is the real type. |
output_mapping returns nothing | JSONPath doesn't match the raw result shape | Inspect the node's raw result; fix the $.… path to match output_schema. |
| "N recipients" where N is char count | Array field given a string | Send ["a@x.com"], not "a@x.com". |
| Whole run fails after one node | A node failed and max_retries was exhausted | Inspect failed_node/error; add a decision recovery branch or raise max_retries for flaky calls. |
APIs used
| Method | Path | Purpose |
|---|---|---|
GET | /api/workflows/task-types | List task types with config/input/output schemas. |
POST | /api/workflows/validate | Validate a workflow definition (requires input_schema). |
POST | /api/workflow-executions | Create an execution (nodes in backend type: "task" format). |
POST | /api/workflow-executions/:id/execute | Queue the run. |
GET | /api/workflow-executions/:id/status | Poll status, outputs, task_states. |
POST | /api/workflow-executions/test-llm-node | Test an AI/agent node in isolation. |
See also
- Visual Workflow Builder — the canvas and the full node palette.
- Node catalog — every node and its per-node config fields.
- Workflows vs. agents vs. squads — when a node should be an agent instead.
- Human-in-the-loop — how
awaiting_user_inputpauses a run.