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.

tsnah
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.

tsnah
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.

tsnah
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:

tsnah
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.

Persist and resume server workflow runs →

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.

Delegation and isolation →

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.

CLI modes and permission handling →

Describing one instead of writing it

Nobody should have to open a file to get a new sequence. /workflow new A workflow that cuts a release to a tag and opens a PR takes the description in plain English and hands it to the agent as a turn. It reads the repository to find the real scripts, paths and refs your words point at, writes down the steps, and then — before it writes a line of code — asks for whatever it genuinely cannot learn on its own: a base ref, a publish path, who signs off. Each question arrives with the default it would have picked and why, so answering is a word, not an interview. The answers come back into the same conversation, and the file lands in .nah/workflows/.

A file there default-exports a function that is handed the API instead of importing it, because a file inside the workspace resolves its modules from the project root and would not find the package. nah looks the file up by name at the moment the workflow is run, so a sequence written mid-session is runnable straight away, and an edited one is picked up rather than served from the first draft. /workflow on its own lists what the workspace has and names any file that will not load.

jsnah
// .nah/workflows/release.mjs
export default ({ createStep, createWorkflow, z }) =>
  createWorkflow({
    id: "release",
    description: "Cut a release tag and open a PR. Needs base: the ref to release from.",
    inputSchema: z.object({ base: z.string().describe("Git ref to tag.") }),
    outputSchema: z.object({ tag: z.string(), pr: z.string() }),
  })
    .then(collectCommits)
    .then(draftRelease)
    .commit();