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.
Choosing between the three
Section titled “Choosing between the three”| 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 |
Delegation is a tool call
Section titled “Delegation is a tool call”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.
Typed parameters
Section titled “Typed parameters”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.
Nothing captures the result
Section titled “Nothing captures the result”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.
One call runs one sub-workflow
Section titled “One call runs one sub-workflow”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:
// 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:
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:
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.
Running a delegated machine standalone
Section titled “Running a delegated machine standalone”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:
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.
What crosses, and what does not
Section titled “What crosses, and what does not”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.
Passing files
Section titled “Passing files”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:
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 pathThe 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
fileargument is areadpath argument of the delegation tool, so the zone and mount rules bind it, and so does apaths:guard on the tool’s allow entry ({ tool: archmax_workflow_reconcile, paths: ["scratchpad/inbox/**"] }). - An argument naming nothing, or a folder, is refused
invalid-parambefore any child runs. - A failed call is recorded, and named in an
on_errorroute, 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.
What the child’s model reads
Section titled “What the child’s model reads”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: trueA 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
Section titled “Bounds”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.
Human decisions inside a sub-workflow
Section titled “Human decisions inside a sub-workflow”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:
archmax decide <sessionId> <target> # a target from the CHILD's transitionsThe 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.
Failure is always closed
Section titled “Failure is always closed”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.
Validation
Section titled “Validation”archmax validate checks delegation statically, before any model call:
- every allow-listed
archmax_workflow_<slug>names a workflow that exists, loads, and declares amanualentry. 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.
Observability
Section titled “Observability”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.