Summary
Add a step type that explicitly ends the workflow with a structured reason and an overall status (success / failure). Today the only way to end a workflow from inside is to route to $end, which carries no information about why it ended.
Motivation
Real workflows have multiple legitimate end states beyond "the last agent finished":
- Early success — "the document is already up to date; no work needed"
- Soft abort — "no matching issues found, nothing to do"
- Hard failure with reason — "the upstream service returned data we cannot process; stop and surface the cause"
- Pre-condition not met — "this PR is from a fork; refusing to run the publish step"
With only $end, all of these collapse into "workflow completed" in the dashboard, JSONL log, and exit code. Downstream tooling (CI, dashboards, notifications) can't distinguish a clean no-op from a real failure.
Proposed shape
agents:
- name: precheck
model: claude-sonnet-4-5
prompt: "Is the input safe to process? Return {\"safe\": bool, \"reason\": string}"
output:
safe: { type: boolean }
reason: { type: string }
routes:
- when: "not precheck.output.safe"
to: abort_unsafe
- to: main_pipeline
- name: abort_unsafe
type: terminate
status: failed # success | failed
reason: "{{ precheck.output.reason }}"
output_template: | # optional
{
"aborted": true,
"stage": "precheck",
"reason": "{{ precheck.output.reason }}"
}
- name: noop_exit
type: terminate
status: success
reason: "Document already up to date; no edits needed."
Behavior
- Reaching a
terminate step ends the workflow immediately (no routes evaluated after).
status: success — exit code 0; dashboard shows ✅; JSONL emits workflow_completed with termination_reason.
status: failed — exit code non-zero; dashboard shows ❌; JSONL emits workflow_failed with termination_reason and is_explicit: true (to distinguish from uncaught exceptions).
output_template: (optional) — Jinja2 expression that becomes the final output: block. If omitted, the workflow's top-level output: mapping is rendered as usual.
- Terminate steps don't have
routes: — schema validator should reject them.
Why now
- Tiny change, big ergonomic improvement for workflows with multiple end states.
- Surfaces in every observability surface we already have (CLI exit code, dashboard, event log, checkpoint metadata).
- Lives in the engine dispatch loop next to the existing
$end handling — couple dozen lines plus schema + tests.
Open questions
- Should multiple terminate steps with the same name be allowed across branches, or must each be unique? Suggest unique names like every other step (consistency with parallel/for-each routing targets).
- Should
status: allow custom strings beyond success / failed? Suggest no — keep it binary for clean integration with exit codes and dashboards.
- Interaction with sub-workflows: a
terminate inside a sub-workflow ends just that sub-workflow (parent sees it as the sub-workflow's result). Document this explicitly.
Acceptance criteria
Summary
Add a step type that explicitly ends the workflow with a structured reason and an overall status (success / failure). Today the only way to end a workflow from inside is to route to
$end, which carries no information about why it ended.Motivation
Real workflows have multiple legitimate end states beyond "the last agent finished":
With only
$end, all of these collapse into "workflow completed" in the dashboard, JSONL log, and exit code. Downstream tooling (CI, dashboards, notifications) can't distinguish a clean no-op from a real failure.Proposed shape
Behavior
terminatestep ends the workflow immediately (no routes evaluated after).status: success— exit code 0; dashboard shows ✅; JSONL emitsworkflow_completedwithtermination_reason.status: failed— exit code non-zero; dashboard shows ❌; JSONL emitsworkflow_failedwithtermination_reasonandis_explicit: true(to distinguish from uncaught exceptions).output_template:(optional) — Jinja2 expression that becomes the finaloutput:block. If omitted, the workflow's top-leveloutput:mapping is rendered as usual.routes:— schema validator should reject them.Why now
$endhandling — couple dozen lines plus schema + tests.Open questions
status:allow custom strings beyondsuccess/failed? Suggest no — keep it binary for clean integration with exit codes and dashboards.terminateinside a sub-workflow ends just that sub-workflow (parent sees it as the sub-workflow's result). Document this explicitly.Acceptance criteria
type: terminateaccepted by the YAML schema withstatus,reason, optionaloutput_templateroutes:/tools:/output:/validator:on terminate stepsstatus:(0 vs non-zero)workflow_completed/workflow_failedevent includestermination_reasonandterminated_by: <step_name>terminateends just the sub-workflow; parent continues normallyexamples/for_each