RUNTIME / WORKFLOWS
The sequence you already know.
Delegation is a decision you make at the moment you need it. A workflow is the part you know every time: which steps, in what order, where they fan out, and where a judgement call belongs. Write it once, name it, and the agent runs it instead of improvising the same tool calls.
Steps and a builder
A step is a function with an id and a schema. Schemas are enforced rather than documentary: a step that returns the wrong shape fails the run rather than feeding a malformed value into whatever reads it next. A workflow composes steps with .then, .parallel, .branch and .map, and .commit() is what turns the builder into a runnable thing — it is where step ids are checked for uniqueness, because those ids key the run's results.
import { createStep, createWorkflow, z } from "not-another-harness";
const collectChanges = createStep({
id: "collect-changes",
inputSchema: z.object({ base: z.string().optional() }),
outputSchema: z.object({ files: z.array(z.string()) }),
execute: async ({ inputData }) => ({
files: await changedFiles(inputData.base ?? "HEAD"),
}),
});
const reviewBatch = createStep({
id: "review-batch",
inputSchema: z.array(z.string()),
execute: async ({ inputData, context, signal }) => {
// `delegate` is put there by the orchestrator that runs the workflow.
const { delegate } = context;
const child = await delegate({
title: `review ${inputData.length} files`,
task: `Review these for correctness bugs: ${inputData.join(", ")}. Edit nothing.`,
signal,
});
return { files: inputData, report: child.text };
},
});
const reviewChanges = createWorkflow({
id: "review-changes",
version: "1", // bump when saved checkpoints become incompatible
description: "Review what changed, one child per group of files, then merge.",
})
.then(collectChanges)
.map({ inputKey: "files", outputKey: "reviews", mapper: (files) => reviewBatch })
.then(mergeReviews)
.commit();Composing without copying
Steps chain without an envelope: when the next step declares an object schema it is fed the matching keys of the previous output, and when none of those keys exist it gets the previous output whole. .parallel runs its branches concurrently and yields the array of their outputs. .branch takes the first matching condition, with an optional otherwise for the fallthrough. A committed workflow nests as a single .then argument, so a review is a unit you reuse rather than three steps you paste twice.
createWorkflow({ id: "ship-check" })
.then(collectChecks) // what does this project run?
.parallel([[typecheck], [lint], [test]]) // concurrently
.branch([[(prev) => prev.some((c) => !c.ok), fixFailures]], {
otherwise: reportSuccess, // first match wins
})
.commit();
const full = createWorkflow({ id: "full-review" })
.then(reviewChanges) // a committed workflow nests whole
.then(shipCheck)
.commit();Stopping for a person
Some steps cannot be decided by code — a diff that needs a human to approve, a deploy that needs a name. Throw StepSuspend and the run returns status: "suspended" instead of failing. Resuming continues from that step and reuses every earlier step's output rather than recomputing it, which is the point: a step that suspended usually sat in front of something expensive.
const run = reviewChanges.createRun();
const first = await run.start({ inputData: { base: "main" } });
if (first.status === "suspended") {
// Nothing before this step is recomputed on resume.
const done = await run.resume({ resumeData: await askHuman(run.snapshot()) });
}
// Or watch it, if you want progress rather than a promise.
const { events, result } = reviewChanges.createRun().stream({ inputData: {} });
for await (const event of events) console.log(event.type, event.stepId);For restart recovery, configure a WorkflowRunStore. On Orchestrator startup, register the workflow definitions and restore the suspended handles before accepting resume requests:
const orchestrator = new Orchestrator({
...options,
workflowRunStore: databaseWorkflowRunStore,
workflowRegistry: { [reviewChanges.id]: reviewChanges },
});
await orchestrator.restoreSuspendedWorkflows();
const waiting = orchestrator.pendingWorkflows();
const continued = await orchestrator.resumeWorkflow(waiting[0].runId, approval);The built-in memory store is for local development; use a database-backed implementation shared by your server instances in production. Set a workflow version and change it when a deployed definition becomes incompatible with checkpoints.
Inside a step, a sub-agent
This is the seam between the two features. Orchestrator.runWorkflow puts its delegate into every step's context, so a step that needs judgement calls a real child agent instead of trying to prompt its way there. A step that delegates waits for a concurrency slot rather than throwing, because the sequence it belongs to is already the orchestrator's queue. The difference from delegating the workflow wholesale: a workflow step shares the caller's workspace and state, while a sub-agent gets its own transcript and its own checkout.
In the CLI
NAH ships two. review-changes collects what differs from a base, groups the files, sends each group to its own reviewing child, and merges the findings — the gathering steps run Git themselves, because spending a model call to obtain a string the shell already knows can get it wrong in a way that only surfaces as a mysteriously empty review. ship-check runs the workspace's own typecheck, lint and test scripts in parallel — only the ones the workspace actually defines, so it never reports a failure the project never claimed to have.
Type /workflow to list them, /workflow review-changes main to run one against a base. The agent has the same two tools: list_workflows and run_workflow, and its prompt tells it to reach for an existing workflow before assembling the same multi-step sequence out of tool calls.