ctx.workflowEngine.start(request) returns a WorkflowRun whose result never rejects — failures resolve with stopReason 'error' and cancellation with 'cancelled'. The current engine runs scripts in worker threads, which isolate the host event loop but are explicitly not a security boundary. Ralph is an ordinary plugin over the same seam, with maxRounds defaulting to 256.
The workflow tool is described everywhere as "orchestrates multi-agent workflows". That is true
and useless. This page is the actual contract — what start() validates, what can and cannot
reject, and which failures are fatal.
The family
| Package | Role | Key |
|---|---|---|
dsh-workflow | Defines execution and lifecycle events | ctx.workflowEngine |
dsh-workflow-worker-thread | Runs scripts in worker threads | registers on ctx.workflowEngine |
dsh-tool-workflow | Exposes general workflow execution to the model | registers on ctx.tools |
dsh-tool-ralph | Exposes the fixed fresh-agent Ralph workflow | registers on ctx.tools |
The seam defines the script, run, result, error and event contracts; an engine decides how to isolate and execute the script. The worker-thread engine is the current one, and a future process or sandbox engine can replace it without changing the tool.
The start request
WorkflowStartRequest = { meta, script, args?, subagentProvider?, maxTotalAgents?, parent, signal? }
start() validates synchronously — a malformed meta block, an unparseable script, an
unavailable provider route or an unsupported per-run limit is rejected before a run exists. That
is a deliberate split: configuration errors fail at the call, not halfway through an orchestration.
Three fields deserve attention:
parent attributes every child agent to the invoking agent, preserving cwd and lineage.
subagentProvider optionally routes every child in that run — without exposing provider
choice to the script. This is how the Ralph tool pins its provider while the ordinary
model-written workflow tool gains no provider selector.
maxTotalAgents optionally lowers the engine's deployment ceiling for one run, and is likewise
invisible to the script.
meta and args are plain data, not script fragments.
The run and result
WorkflowRun = { id, meta, result, cancel(reason?), dispose() }
WorkflowResult = { value, stopReason, error?, agentsStarted }
The property to internalise: once start() returns, result never rejects. Execution failures
resolve with stopReason: 'error'; cancellation resolves with cancelled within the engine's
bounded grace. You handle outcomes by reading stopReason, not by catching.
value is plain JSON data or null.
A run is holder-owned: engine-plugin unload prevents new starts but does not revoke accepted
runs, and the holder must call dispose() on every path. Disposal cancels remaining work and
reaches or abandons quiescence within a documented bound.
Events are observe-only
workflow/start and workflow/end pair the run. workflow/phase and workflow/log expose script
narration. workflow/agent-start and workflow/agent-end pair each child call by seq — and a
child whose async provider start rejects emits neither.
The design detail worth stealing: events carry WorkflowRunInfo (id plus meta) rather than the
live run, so listeners cannot acquire cancellation or disposal authority. Observation does not
grant control.
Same-process payloads are borrowed immutable values, and every listener is independently contained — a synchronous throw or rejected promise is logged without starving peers or changing execution.
The nine error codes
WorkflowError carries a code and a fatal flag. Fatal errors always escape parallel() and
pipeline() instead of becoming an ordinary per-item null.
| Code | Meaning |
|---|---|
SCRIPT_PARSE | The script could not be parsed |
META_INVALID | The meta block is invalid |
INVALID_ARGUMENT | A hook call violates the engine contract |
UNSUPPORTED_OPTION | An option the engine does not support |
UNSUPPORTED_SCHEMA | A schema the engine does not support |
AGENT_CAP | The configured agent limit was exceeded |
ITEM_CAP | The configured item limit was exceeded |
AGENT_START | The provider's async start rejected |
AGENT_RESULT | A published child's result rejected with an infrastructure fault |
RESULT_UNSERIALIZABLE | A script or worker value is not plain JSON data |
CANCELLED | Cancellation owns the run; pending and future hooks reject |
And the distinction that decides how you write scripts:
That is why the idiomatic pattern is .filter(Boolean) after a fan-out — a null means "that child
did not complete", not "the orchestration broke".
Ralph, as an ordinary plugin
tool-ralph is worth reading as a design exhibit: it implements a specialised orchestration policy
as an ordinary plugin over ctx.workflowEngine and ctx.subagents. No Ralph mode was added to
agent-loop, and the same-session goal domain stays independent.
ralph({ objective, maxRounds? }) waits for the whole run.
| Config | Default | Meaning |
|---|---|---|
subagentProvider | spawn | Fresh structured-output provider used for every round |
maxRounds | 256 | Default and deployment ceiling for one Ralph run |
maxHandoffChars | 16384 | Maximum serialized characters in one round report |
maxResultChars | 16384 | Maximum characters in the successful parent result |
The provider must exist, support structured output, and report inheritsParentContext: false — the
fresh-child property is a requirement, not a preference.
Each child receives only the immutable objective, its current round and cap, a shared-workspace-as-authority instruction, and the previous structured handoff. The workspace is long-term memory; parent conversation and prior child sessions are not seeded.
Reports carry status: continue | complete | blocked, a non-empty summary, evidence, next steps and
blocker text. Invalid, missing or oversized reports fail the workflow rather than being truncated
or mistaken for cap exhaustion — a deliberate refusal to let a malformed handoff look like a
finished loop.
The terminal result is complete, blocked or budget-limited. Ralph does not retry a failed
round; the error names the round and retains the last successful handoff.
The one limitation to plan around
Foreground collection only. The caller owns one live run and awaits it; background start/poll, spill handles and detached collection are deferred. If you were planning to fire a long workflow and poll it later, that is not available yet.
Frequently asked
Are workflow worker threads a security boundary?
No. The repository states plainly that worker threads isolate workflow execution from the host event loop but are not a security boundary. Do not treat them as a sandbox for untrusted scripts.
Can WorkflowRun.result reject?
No. Once start() returns, result never rejects. Execution failures resolve with stopReason 'error' and cancellation resolves with 'cancelled' within the engine's bounded grace. Validation failures happen synchronously in start(), before a run exists.
What is the difference between a child failing and a fatal error?
A child that resolves normally with a non-completed stop reason is not an exception — agent() returns null so the script can handle it. Fatal WorkflowErrors always escape parallel() and pipeline() instead of becoming a per-item null.
How many rounds can a Ralph loop run?
maxRounds defaults to 256. The deployment config's value is both the default and a ceiling on a per-call override, and the engine rejects a cap above its own deployment ceiling before publishing a run.