Skip to content
archmax.ai

Sub-workflows

A sub-workflow is another workflow in the same workspace, run to completion from inside a session as a child session. It is a whole machine: its own states, its own per-state tools.allow, its own lifecycle hooks. That is what separates it from a script, which is deterministic work with no model call, and from a grading rubric, which is a verdict on work already done.

Delegate to one when the work you are handing off is itself a process, or when the same process is reused by more than one workflow.

Use When
Subagent isolated reasoning inside one state; no states, no governance boundary
Script deterministic work with no model call, including a delegation that must happen, or a fan-out computed from data
Sub-workflow the delegated work has states, governance, or hooks of its own; or it is reused

A state that may run another workflow says so in its tools.allow:

states:
triage:
triggers:
manual:
instructions: >-
Enrich the order the customer asked about by calling
archmax_workflow_enrich-order with its id, then record what it returned.
requires:
- enrichment
tools:
allow:
- archmax_workflow_enrich-order
transitions:
- to: report
description: The order has been enriched.

That entry is the whole declaration. The runtime reads enrich-order’s manual trigger and binds a tool named for it, using the slug verbatim. So the tool name, the workflows/<slug>/ directory, the mock key and the ranWorkflow assertion are one string you can grep for.

The signature comes from the target, not from the caller:

archmax_workflow_enrich-order({ order_id: "ORD-1003" })
→ { message: "Enriched ORD-1003.",
returns: { enrichment_file: "scratchpad/attachments/ORD-1003.json", delayed: true } }

Its parameters are the target’s requires:, each marked required, and its result carries the target’s returns: alongside the child’s closing message. A target that declares no returns: answers with the message alone.

A target that types its signature hands the caller’s model a typed tool schema. { name: quantity, type: integer } becomes quantity: { type: integer }, { name: due, type: date, description: … } becomes due: { type: string, format: date, description: … }, and a bare name stays an untyped parameter. The tool’s description leads with the manual trigger’s description, so the calling model reads what the call does first. The target’s instructions never reach the caller.

A call whose argument does not conform is refused with invalid-param before any child is composed, naming the argument, its type and what arrived. A missing argument is still missing-param, which is reported first.

An argument may reference the caller’s variables. One that is exactly one reference, such as "${{count}}", is seeded with the referenced value itself, so a number stays a number and satisfies an integer parameter. An argument that mixes text with references, such as "order ${{order_id}}", is substituted as text. An unresolvable reference fails the call with unresolved-param.

A child that finishes with some of its returns unset is not sent back to set them. It completes, and the call answers with every declared return, the unset ones empty (null), plus a note naming them:

{
"message": "Invoice INV-159123 found.",
"returns": {
"corrected_invoice_number": "INV-159123",
"description": null,
"note": "Not all return variables were set by the sub-workflow: 'description' was not set."
}
}

The calling agent decides what to do about the gap: carry on, ask, or call again. No model call is spent on a second attempt. The returns are held to their types too: a typed return holding another kind of value fails the call with invalid-return, naming the state, the variable and the type, and no partial result reaches the caller then. note is reserved, so a returns list may not declare it.

The tool surface is closed by default, so a state reaches exactly the targets it names. Delegation needs no rule of its own. policy.forbid_tools blocks it like any other tool.

The result reaches the caller as a tool result, with no capture variable in between. An agent that wants it as a session variable sets it with archmax_set_variables. A script writes the value it just received.

What makes that dependable is the calling state’s requires:. A state that must produce enrichment declares it, and archmax_advance out of that state is refused until the variable is set. That is the rule already governing every other variable a state must produce.

Fan-out needs no argument, because both ways of calling already have one:

  • an agent fans out by emitting parallel tool calls;
  • a script fans out with Promise.all, which genuinely runs the calls concurrently across the sandbox bridge.

Both are bounded by the dispatcher’s maxConcurrent, which queues the excess. Each call succeeds or fails on its own, so a caller learns which child session failed.

Where the fan-out must be deterministic, write a script:

scripts/enrich-orders.js
// The bridge camelCases tool names, and a tool result arrives as text.
const orders = args.variables?.orders_to_enrich?.value ?? [];
const enriched = await Promise.all(
orders.map(async (entry) => {
const answer = await tools.archmaxWorkflowEnrichOrder({ order_id: entry.order_id });
const { returns } = JSON.parse(answer);
return { order_id: entry.order_id, ...returns };
}),
);
enriched;

The list never enters the model’s context, and the script decides how many run.

The target: its manual entry is its call signature

Section titled “The target: its manual entry is its call signature”

A caller enters a machine where a host would, at its manual trigger. That entry is enough on its own:

workflows/enrich-account/workflow.yaml
states:
plan:
triggers:
manual:
requires: [account_id] # what a caller must supply
returns: # what this machine guarantees it sets
- { name: enrichment_file, type: file } # copied back to the caller
- risk_level
transitions:
- to: write
write: {}

Those two lists are the machine’s signature, enforced whichever ingress started the session:

Half When it bites
requires a call short of any declared name, or with a value that does not conform to a typed entry, is refused before this workflow is composed
returns a child that finishes with a declared name unset completes, and its caller gets that name empty (null) plus a note; a top-level session is rejected; a typed one mistyped rejects either way

archmax validate checks the caller against the same declaration, offline.

Declaring the signature is optional. A trigger with neither key is callable and takes no declared parameters, which is what a bare manual: says.

Because the entry a caller uses is the entry a host uses, the same machine runs standalone on the same inputs:

Terminal window
archmax run enrich-account --variables '{"account_id":"acct-42"}'

${{trigger}} reads manual either way, so a machine cannot branch on having been delegated.

Who may call it is decided entirely on the caller’s side. A state calls archmax_workflow_<slug> when it allow-lists that name, and an ancestor’s deny rules still bind inside the child. That is the same check every other tool capability passes.

A child session carries no prose instruction. It works from its own state instructions, which reach the model through the system prompt, and the inputs it was seeded with, whose short scalar values its opening message lists (see what the child’s model reads). So a parent has nothing to write or keep in sync.

session: and message: on the trigger are host-facing keys a delegation ignores. A child session’s id is derived from its caller’s, and a delegation has no message channel to resolve.

A call’s arguments are the child session’s initial variables, supplied by a caller instead of a host, built by the same function and validated by the same name rule.

So a delegated machine runs on its own, given the same inputs:

Terminal window
archmax run enrich-order --trigger manual \
--variables '{"order_id":"ORD-1003"}'

That is how you develop and debug a child in isolation, one order at a time.

A child session is the same agent: same model, backend, host tools. It has its own grading rubrics, read from its own spec, and it is governed by the child’s machine, starting with a fresh transcript.

Direction Crosses
Down the call’s arguments, locked as session variables (except title, and any the target also returns, which stay unlocked)
Up the declared returns and the result message; a file return arrives as a copy (see passing files)
Never the child’s other variables, its title, its transcript, its audit trail, or any other file it wrote

The child is its own session, <parent>~<n>, with its own checkpoints and its own file space. n is the dispatch’s number in the calling session: the first call the session ever makes is ~1, the next ~2, counted across all its turns, and a grandchild is <parent>~<n>~<m>. archmax sessions lists it with parentSessionId naming the caller. Its scratchpad/ is not its caller’s: a path it reads or writes resolves inside its own session, so it can neither see nor overwrite a file of its caller’s. What the caller reads back is the tool result. The only files that cross are copies the runtime makes of declared file arguments (down) and file returns (up).

The count is part of the calling session’s checkpointed state, so it continues whichever agent or worker runs the next turn: a host that builds an agent per turn never hands two dispatches one child. The model’s delegation calls in one batch are numbered in the order it made them, and a script’s or a hook’s dispatch takes the next number free.

A host tool’s file work inside a child belongs to the child. A host tool called in a child session is handed the child’s workspace and, as ToolContext.sessionId, the child’s id. A session’s files live under <sessionId>/ in the session store, so a host that reads or writes them through its own store handle keys them by ToolContext.sessionId. Keyed by the session the host sent the turn to, a child’s writes would land in its caller’s folder, where the child’s own read_file and ls never look. See host tools.

A child session sees only what its caller declared. The delegating session’s own variables stay behind, so anything the child needs must be named as an argument. That keeps its inputs readable at the call site.

When the child session finishes, the variables named in its trigger’s returns: are read out of its settled store, and everything else it set is discarded. That is the only moment they are read. The child may leave any state without them and replace a value it set early. An argument that seeds one lands unlocked, and its agent locks one only when it chooses to. So the caller gets the value the child finished with. See a return is owed when the session finishes.

The child’s title is among the things that stay behind. It is refused in returns: so it cannot be smuggled up. A child session’s title names the child session’s task, and merging it would rename the caller’s.

A child is not asked to name its session either (see below). A caller that wants the child working under a particular label passes one down as an argument. It lands unlocked there and stays as passed, unless the child’s own instructions tell it to write another. See Two reserved names.

The caller reads the declared returns off the tool result:

archmax_workflow_enrich-account({ account_id: "acct-42" })
→ { message: "Enriched acct-42.",
returns: { enrichment_file: "scratchpad/attachments/acct-42.json", risk_level: "low" } }

The two keep separate channels. The message is prose the model reads, and the returns are values a guard or a script reads by name.

A child’s scratchpad/ is not its caller’s, so a path means nothing on the other side of the call. A file crosses only as a copy the runtime makes, and only for an entry the target declares type: file:

workflows/reconcile/workflow.yaml
states:
work:
triggers:
manual:
requires: [{ name: invoice, type: file, description: The invoice to reconcile. }]
returns: [{ name: report, type: file }]

Down. The caller passes a path in its own workspace: a session file such as scratchpad/inbox/invoice.pdf, or an authored one such as skills/billing/assets/template.pdf. Before the child starts, the runtime copies the file’s exact bytes into the child’s scratchpad/attachments/ under the same name, and seeds the child with that path:

archmax_workflow_reconcile({ invoice: "scratchpad/inbox/invoice.pdf" })
# the child holds scratchpad/attachments/invoice.pdf, and its `invoice`
# variable (and opening message) read that path

The child works on its own copy, so nothing it does changes the caller’s file. Two arguments with the same file name take -1, -2, … suffixes in declaration order. The read is the calling state’s:

  • A file argument is a read path argument of the delegation tool, so the zone and mount rules bind it, and so does a paths: guard on the tool’s allow entry ({ tool: archmax_workflow_reconcile, paths: ["scratchpad/inbox/**"] }).
  • An argument naming nothing, or a folder, is refused invalid-param before any child runs.
  • A failed call is recorded, and named in an on_error route, with the path the caller passed, not the child’s copy.

Up. When the child finishes, the runtime copies each file return it set into the caller’s scratchpad/attachments/ and hands the caller that path:

→ { message: "Reconciled.", returns: { report: "scratchpad/attachments/report.pdf" } }

The copy never overwrites anything. When the name is taken, by the caller’s own file, an earlier call or a sibling call finishing at the same moment, the copy gets the lowest free suffix (report-1.pdf, report-2.pdf). So ten parallel calls returning report.pdf leave ten files, each call naming its own. The child’s original stays in the child’s session.

A child can hand back only what its caller could read on runtime authority. Every forbid_always list of every ancestor binds the child, and a file value must name a file the session may read under those rules and its own workflow’s mount grants. So a child cannot return a file its caller’s workflow denies: the agent’s write of such a return is refused, and a value a script wrote is rejected when the child completes. A return whose file is gone when the child completes, or that cannot be copied, fails the call invalid-return. A child that parks at a human state copies its returns when it finally completes, and the note the caller then reads carries them beside the child’s closing message.

For a case, a mocked file return names a file in the calling session itself, seeded with workspace:, and it is copied into scratchpad/attachments/ like a real one. See testing.

A child goes straight to its work. Its first model call is handed everything it needs to start, so a one-state child whose inputs are short scalars spends no call naming its session and none reading its inputs.

Its inputs, in its opening message. A child’s transcript starts with one message from the runtime, marked as an opening runtime note. It lists the call’s arguments by name, alphabetically, with their values:

Begin. Your instructions are already in context. You were started with these inputs:
- amount: 412.5
- invoice_id: "INV-159123"
- urgent: true

A string is shown JSON-quoted, so a value reads as data: a line break or a - name: inside it stays inside the quotes and cannot pass for a line of the message. Numbers and booleans are shown as written. A value is left out, and only named, when it is anything else (a list, an object, null), when its rendering runs past 200 characters, or when it would take the message’s values past 1,000 characters in all:

Not shown here: lines. Read one with archmax_get_variables when you need its value.

The message mentions archmax_get_variables only when it names an input like that. An input left out can still be passed to a tool whole as ${{lines}}, without being read first.

No title step. A child’s title describes a session nothing lists, and it is never returned, so a child’s prompt does not ask for one. Its platform prompt leaves out the “Name the run first” step and the paragraph explaining title, and its archmax_set_variables description leaves out the sentence asking for one. The rest of its platform prompt reads as a top-level session’s does, with the movement steps renumbered. A top-level session reads all of it, so its cacheable prefix does not depend on whether it delegates.

A workspace that overrides the platform prompt decides what its children leave out the same way. A passage between a <!-- top-level-only --> line and a <!-- /top-level-only --> line is read by a top-level session and left out of a child’s, and the marker lines reach neither. Where a left-out passage held items of a numbered list, the items after it are renumbered. An override without the markers reads the same in both sessions.

Bounds are the dispatcher’s configuration. A workflow cannot know how deeply someone else will delegate to it:

createSubWorkflowDispatcher({
registry,
machine,
bounds: { maxDepth: 3, maxConcurrent: 4 }, // the defaults
});

Excess concurrent dispatches queue rather than fail. Exceeding the depth, or delegating to a workflow already running in the chain, is refused before anything is composed.

A delegated machine may declare a type: human state. When a child session reaches one, the child suspends and the delegating session suspends with it. The session parks exactly as it would at the parent’s own human state.

The decision presented is the child’s: its state, its instructions, its evidence, its declared transitions. That is the decision the person is actually being asked to make. Resolve it the usual way:

Terminal window
archmax decide <sessionId> <target> # a target from the CHILD's transitions

The child continues from that state, finishes, and the delegating session carries on with its result.

The park does not unwind the calling turn. The suspension is recorded and the turn ends, so if one of five concurrent calls stops for a person:

  • the session parks, and the four that answered keep their results;
  • resolving the decision resumes just the child that stopped, so each call is dispatched once;
  • the child’s answer then arrives in the transcript as [sub-workflow: <slug>] …, a runtime note, and a fresh turn continues in the same state.

Several parked children are presented one at a time. The session parks again while any remain, so approving five refunds is five decisions on one session.

A child session fails when it is rejected, exceeds its budget, cannot be loaded, or completes with a typed return of the wrong kind (invalid-return). The call answers with a tool error naming the workflow and the reason. A child that merely left a return unset does not fail: its caller gets the rest with a note (see above).

A child whose agent ended it with archmax_raise fails the call the same way, with the kind raised. The tool error names the child workflow, the state it raised in, its code and its reason (Sub-workflow 'enrich' failed: its agent raised 'customer-unknown' in state 'work': …), so the calling agent can act on the code. A child that raised owes no returns, so the error is never reported as a missing return. To pass the failure up, the calling agent can raise in turn, with its own code.

The calling agent can then retry with different inputs, route around it, or stop. Each failed call is the calling state’s failure, recorded on its own with the arguments it was made with, and it stays outstanding until a later call of the same workflow from the same state succeeds. One success recovers one failed call: the one whose arguments it repeats, else the oldest one outstanding. Arguments are compared after their ${{…}} references resolve, and key order does not matter. So a retry with the same arguments recovers its own failure, a retry with corrected arguments recovers one too, and the trail still records every call. When the model finishes with any failure still outstanding, the state’s on_error catches it as it catches any other turn failure, or the turn ends rejected without one. The route names every outstanding failure with the arguments of its call.

After calls to enrich from start for ORD-1 and ORD-2 both fail The failures
a later call to enrich for ORD-1 succeeds ORD-1’s is recovered; ORD-2’s still routes
later calls for both succeed both are recovered
a later call to enrich for ORD-9 succeeds the oldest one is recovered; the other still routes
a call to another workflow succeeds both still stand
a sibling call to enrich in the same tool batch succeeds still stand: a recovery has to come from a later model step
the agent advances, parks, or calls archmax_reset are cleared, as a committed rejection always was
the state also has a rejection with another cause (a terminal kernel block, an exhausted park budget) that rejection still routes: only a failed delegation can be recovered

Where the agent cannot recover, the state’s requires: holds it in place until the work is genuinely done.

Refusals the runtime makes before anything runs are blocked calls rather than tool errors: depth, a cycle, a missing required input (missing-param), an input of the wrong type (invalid-param), an unresolvable reference (unresolved-param), a disabled target. No child was composed, so the caller may correct and retry within the turn.

A target that declares disabled: true is refused this way. The call is blocked before a child is composed, naming the flag and how to undo it. The refusal wins over a declared mock, so an offline case can never assert a delegation production would refuse.

A disabled target leaves the caller’s assembly valid, and its tool is still bound: disabling one leaf workflow should leave every caller, and every caller’s parked session, running. Re-enabling it is picked up on the next dispatch, with no re-assembly.

Concurrent calls fail independently: one failure leaves the others’ results intact.

Denials compose downward and accumulate. Every ancestor’s policy.forbid_tools / forbid_paths binds inside the child: the root’s and each intermediate caller’s. The block reason names the workflow that declared the rule. A child may narrow what is permitted, and widening it takes a change upstream.

archmax validate checks delegation statically, before any model call:

  • every allow-listed archmax_workflow_<slug> names a workflow that exists, loads, and declares a manual entry. Each problem is reported against the state that allowed it, in the wording assembly would use;
  • no self-reference, no cycle, no chain deeper than maxDepth, computed over what each workflow may call;
  • every allow-listed target that is currently disabled draws a warning, not an error, reported against the state that allows it. The caller’s spec is valid, and the call would be refused at runtime.

Whether a call supplies the target’s requires:, with values of the declared types, is not statically knowable, because the arguments are the model’s or a script’s. That check is the dispatch-time refusal, which happens before the child is composed.

Everything a child session emits carries its own sessionId and the dispatch’s subWorkflowDispatchId, its streamed text included. A child’s agent-text-delta and agent-text events are the child’s, never its caller’s, so two children answering at once stream as two attributable messages rather than as the calling state’s text.

Each dispatch emits sub-workflow-start / sub-workflow-result, paired by dispatchId: the child’s session id below the root session, so 1, 2, and 1~1 for the first dispatch made inside 1. One turn’s whole tree of dispatches shares one event log, and no two of them share an id. The result carries the returned variables’ names, never their values: the event stream is a diagnostic channel. It also carries the id of the tool call that started it, so concurrent child sessions stay individually attributable.

The audit trail records one sub-workflow step per child session, naming the child and whether it succeeded. That step is addressed to the state that made the call, since a delegation traverses no edge and the session is in the same state before and after. The child’s own trail stays on the child session, readable from its own summary.