Composable Workflows
Martha workflows support conditional routing, LLM-based classification, sub-workflows, and parallel execution. This lets you build complex multi-step processes from simple building blocks.
This guide covers:
- Workflow designers — how to use choice, LLM router, sub-workflow, and parallel nodes
- Developers — how the executor processes these nodes and what to expect
Node Types
Choice Node (Conditional Routing)
A choice node evaluates a variable against a list of conditions and routes to the first matching branch.
{
"id": "route_by_priority",
"type": "CHOICE",
"config": {
"eval_variable": "{{inputs.priority}}",
"conditions": [
{ "operator": "equals", "value": "urgent" },
{ "operator": "equals", "value": "normal" }
]
}
}The executor evaluates conditions top-to-bottom. The first match routes to branch-0, the second to branch-1, etc. If nothing matches, the default branch is taken.
Edges
Connect choice outputs to downstream nodes using source_handle:
[
{ "from": "route_by_priority", "to": "urgent_handler", "source_handle": "branch-0" },
{ "from": "route_by_priority", "to": "normal_handler", "source_handle": "branch-1" },
{ "from": "route_by_priority", "to": "fallback_handler", "source_handle": "default" }
]Operators
| Operator | Description | Example |
|---|---|---|
equals | Exact string match | "urgent" matches "urgent" |
not_equals | Inverse of equals | |
contains | Substring check | "error" matches "connection error" |
not_contains | Inverse of contains | |
greater_than | Numeric comparison | "100" > "50" |
less_than | Numeric comparison | |
matches_regex | Regex pattern match | "^ERR-\\d+$" matches "ERR-404" |
is_empty | None, "", [], {} | |
is_not_empty | Inverse of is_empty |
Template Values
Both eval_variable and condition value support {{...}} templates:
{{inputs.field}}— user inputs passed to the workflow{{inputs.nested.path}}— nested input fields
!!! warning "Condition values cannot reference {{nodes.*}}" For security, condition values are restricted to {{inputs.*}} templates. Referencing other node outputs in conditions is not allowed.
LLM Router (AI Classification)
An LLM router uses an AI model to classify text input and route to the matching branch.
{
"id": "classify_intent",
"type": "CHOICE_LLM",
"config": {
"eval_variable": "{{inputs.message}}",
"llm_config": {
"model": "anthropic/claude-sonnet-4-5-20250929",
"temperature": 0
},
"conditions": [
{ "operator": "equals", "value": "billing" },
{ "operator": "equals", "value": "support" },
{ "operator": "equals", "value": "sales" }
]
}
}The LLM receives a classification prompt with the configured categories and input text. Its response is matched against conditions using the same operators as choice nodes.
LLM Config
| Field | Default | Description |
|---|---|---|
model | gpt-4o-mini | Any model supported by LiteLLM |
temperature | 0 | Lower = more deterministic classification |
prompt | (built-in) | Custom classification prompt. Use {input} and {categories} placeholders. |
If the LLM returns an unrecognized category, the default branch is taken. If the LLM call fails, the default branch is also taken (with an error dict).
Sub-Workflow (Composition)
A sub-workflow node executes another workflow as a child, with input/output mapping between parent and child.
{
"id": "run_validation",
"type": "SUB_WORKFLOW",
"config": {
"workflow_name": "validation_workflow",
"input_mapping": {
"document_id": "{{inputs.doc_id}}",
"strict_mode": true
},
"output_mapping": {
"validation_result": "nodes.validator.output"
},
"timeout_seconds": 120,
"on_error": "abort"
}
}Config Fields
| Field | Required | Default | Description |
|---|---|---|---|
workflow_name | Yes | — | Name of the child workflow to execute |
input_mapping | No | {} | Map parent context values to child inputs. Supports {{...}} templates. |
output_mapping | No | {} | Map child result paths back to parent. Uses dot-notation (e.g., nodes.step.output). |
timeout_seconds | No | 300 | Maximum execution time for the child workflow |
on_error | No | abort | abort = propagate error, fallback = use fallback_value |
fallback_value | No | {} | Value returned if on_error is fallback and the child fails |
Cycle Detection
Martha prevents recursive loops. If workflow A calls workflow B which calls workflow A, the cycle is detected at runtime and returns an error:
Recursive sub-workflow detected: wf_b -> wf_a -> wf_bMaximum nesting depth is 3 levels. Deeper nesting returns an error immediately.
Parallel Execution
A parallel node executes multiple branches concurrently and merges results.
Edge-Based Branches (Recommended)
Define parallel branches through edges with source_handle: parallel-*:
[
{ "from": "parallel_start", "to": "branch_a", "source_handle": "parallel-0" },
{ "from": "parallel_start", "to": "branch_b", "source_handle": "parallel-1" }
]Branches discovered from edges run concurrently and merge into a single dict keyed by branch node ID.
!!! note "Branch limit" A maximum of 20 parallel branches are allowed per node. This prevents resource exhaustion from malformed workflow definitions.
Combining Node Types
These node types compose naturally:
- Choice -> Parallel: Route to different parallel execution groups based on input
- Sub-workflow with Choice: A child workflow can contain its own choice routing
- LLM Router -> Sub-workflow: Classify intent, then run the appropriate sub-workflow
- Parallel -> Choice: Run branches in parallel, then route based on merged results
Diamond Convergence
Multiple branches can converge back to a single node:
Start -> Choice -> Branch A -> End
-> Branch B -> EndBoth branches connect to End, but only the taken branch executes. The end node sees results from whichever branch ran.
Authorization (run-as identity)
When a workflow has a function node (a node that calls a tool such as query_collection, generate_report, or any platform/HTTP function), that function executes under a run-as principal — the client of whoever triggered the workflow:
- Triggered by a user (admin UI / CLI / API, no client selected) → runs as the tenant's default client.
- Triggered by a service account (e.g. an integration calling
POST /api/service/workflows/{name}/execute) → runs as that service account's client.
The function node is then authorized against that client's grants. So to let a workflow's query_collection node run, grant the function to the client, not the agent:
martha clients grant <client> function query_collectionAgent grants don't apply to workflow function nodes.
martha agents add-function <agent> <fn>grants a tool to an agent — that applies when the agent is calling the tool (in chat / an agent loop). A workflow function node does not run "as" an agent, so an agent grant has no effect there. Grant the client the workflow runs as.
If the run-as client isn't granted the function, the node fails with a clear 403 "not authorised" (not a generic error). Running a workflow also requires the client to have access to the workflow itself (martha clients grant <client> workflow <name>); user/admin runs see all tenant workflows.
This mirrors how automation platforms scope steps to a configured service identity rather than the runner's ambient permissions: a workflow's authority is the client it runs as, plus the functions that client is granted.
Related
- Iteration Nodes — For-each, filter, and reduce for array processing within workflows
- CLI —
clients grant/agents add-functionfor managing access - Document Ingestion —
query_collectionand the RAG retrieval functions used in workflow nodes