Skip to content

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.

json
{
  "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:

json
[
  { "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

OperatorDescriptionExample
equalsExact string match"urgent" matches "urgent"
not_equalsInverse of equals
containsSubstring check"error" matches "connection error"
not_containsInverse of contains
greater_thanNumeric comparison"100" > "50"
less_thanNumeric comparison
matches_regexRegex pattern match"^ERR-\\d+$" matches "ERR-404"
is_emptyNone, "", [], {}
is_not_emptyInverse 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.

json
{
  "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

FieldDefaultDescription
modelgpt-4o-miniAny model supported by LiteLLM
temperature0Lower = 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.

json
{
  "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

FieldRequiredDefaultDescription
workflow_nameYesName of the child workflow to execute
input_mappingNo{}Map parent context values to child inputs. Supports {{...}} templates.
output_mappingNo{}Map child result paths back to parent. Uses dot-notation (e.g., nodes.step.output).
timeout_secondsNo300Maximum execution time for the child workflow
on_errorNoabortabort = propagate error, fallback = use fallback_value
fallback_valueNo{}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_b

Maximum nesting depth is 3 levels. Deeper nesting returns an error immediately.


Parallel Execution

A parallel node executes multiple branches concurrently and merges results.

Define parallel branches through edges with source_handle: parallel-*:

json
[
  { "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 -> End

Both 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:

bash
martha clients grant <client> function query_collection

Agent 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.

  • Iteration Nodes — For-each, filter, and reduce for array processing within workflows
  • CLIclients grant / agents add-function for managing access
  • Document Ingestionquery_collection and the RAG retrieval functions used in workflow nodes

Martha is built by aiaiai-pt.