Run A Workflow
How to execute a workflow definition against a world, with options, return shape, errors, and streaming.
Little Workflow: published on npm as little-workflow@0.2.0-alpha.1 (2026-10-06). Sections labeled UNRELEASED describe source-only changes. Release status
runWorkflow() is the alpha entry point. It provisions a one-shot runtime, executes one run, returns the result, and tears the runtime down. It suits scripts, tests, and the kill-and-resume demo. A long-lived runtime for processes that serve many runs is not part of the alpha API. To serve workflows from a long-lived agent process, hand them to Little Harness; see Compose With Little Harness.
runWorkflow(workflow, input, options?) runs one workflow. workflow.run(input, options?) on a defineWorkflow result is the same call. The deprecated orchestrator, which composes several workflows, has its own function, runOrchestration (see below).
Single Workflow
import { localWorld, runWorkflow } from "@little-stack/workflow";
const result = await runWorkflow(
summarizeTicket,
{
ticketId: "TCK-123",
transcript: "Customer reports intermittent billing export failures...",
},
{
world: localWorld({ dataDir: ".little-workflow" }), // optional; this is the default
runId: "run_tck_123",
timeout: "10m",
},
);
console.log(result.runId);
console.log(result.output);runWorkflow returns a promise that resolves when the run reaches RunCompleted, or rejects on RunFailed. Internally it starts a planner harness session to compile the workflow to LWIR (a fixed-plan workflow skips the model call), registers a locked WorkflowVersion, executes the steps via worker harness sessions, and persists every event and artifact into the world. Reusing the same runId on a terminal run replays durable state instead of calling the planner, tools, or models again.
Multiple Workflows (Orchestration, Deprecated)
Deprecated (
LWF_DEP_ORCHESTRATOR).runOrchestrationis being retired. To compose workflows, adapt each one withasHarnessWorkflowand pass them tocreateHarness({ workflows }); see Compose With Little Harness. It still works in this release and emits a one-timeDeprecationWarning.
runOrchestration runs an orchestrator: a harness session with tools to plan and run sub-workflows. It takes the workflows, the shared input, and the orchestrator config in one options object.
import { runOrchestration } from "@little-stack/workflow";
const result = await runOrchestration({
workflows: [candidateReview, candidateScoring],
input: { jobDescription, candidates: [...] },
tools,
orchestrator: {
model: openai("gpt-5"),
system: "Orchestrate candidate evaluation. Plan once per job description; spawn parallel runs per candidate.",
skills: [skill("./skills/batch-fan-out/")],
},
maxConcurrentSubRuns: 20,
runId: "run_eng_123",
});The orchestrator has tools to compose sub-workflows:
plan_workflow(workflowId, input)— draft an LWIR for one workflow; returns aworkflowVersionId.run_workflow(workflowVersionId, input)— execute a previously-planned version with the given input.start_workflow(workflowId, input)— convenience for plan + run in one call.
For batched parallel work (same workflow shape, many items), the orchestrator calls plan_workflow once then run_workflow N times in parallel tool calls. Each sub-workflow run has its own runId reserved in harness.tool_call.started.payload.args.subRunId and returned as harness.tool_call.succeeded.payload.result.runId. See Orchestrator for the full composition model.
Options
type RunWorkflowOptions = {
// Core
world?: World; // defaults to localWorld()
tools?: ToolRegistry; // defaults to the tools declared inline on the workflow
mcp?: HarnessMcpConfig; // declarative MCP servers, exposed as gateway tools
bash?: BashCapabilities; // run-level default for harness Bash tool capabilities
permissions?: ToolPermissions; // rule-based allow/deny/ask gate on tool calls (opt-in; default allow)
// Run identity + lifecycle
runId?: string;
timeout?: string | number;
signal?: AbortSignal;
label?: string; // human-readable run label; defaults to the workflow's label, then its id
tags?: readonly string[]; // free-form tags for filtering runs
trigger?: RunTrigger; // how the run started, recorded on its RunStarted event
progress?: (event: WorkflowRunProgressEvent) => void | Promise<void>;
workflowVersionReuseStrategy?: WorkflowVersionReuseStrategy;
// Retry + outer loop
maxAttempts?: number;
maxOuterCycles?: number;
outerLoopId?: string;
};
// DEPRECATED: runOrchestration's options
type RunOrchestrationOptions = Omit<RunWorkflowOptions, "maxOuterCycles" | "outerLoopId"> & {
workflows: readonly WorkflowDefinition[];
input: unknown;
orchestrator: OrchestratorConfig;
maxConcurrentSubRuns?: number; // cap on simultaneous sub-workflow runs; default 10
};
type OrchestratorConfig = { // deprecated
model: AiSdkModel | ModelSlot;
harness?: Harness; // optional; defaults to workflowHarness
system?: string;
skills?: readonly Skill[];
skillMaxRisk?: SkillRiskLevel;
skillOidcToken?: SkillOidcToken;
tools?: ToolSet; // extra tools beyond SDK-injected (plan_workflow, run_workflow, start_workflow, bash)
bash?: BashCapabilities; // orchestrator-session override
};world— the runtime backend. Defaults tolocalWorld(), which stores runs in.little-workflow/; passlocalWorld({ dataDir })to use another directory. Production worlds (Postgres + Redis + S3) arrive after alpha.input(the second argument) — validated against the workflow'sinputschema before the run starts. A mismatch throws before any model is called.tools— aToolRegistrycreated withcreateToolRegistry(). Defaults to the tools declared inline on the workflow (defineWorkflow({ tools: { ... } })). Required when the workflow names tools (tools: ["name"]) that it does not declare inline.mcp— declarative MCP servers. Little Harness resolves them into gateway tools (normallymcp_list_toolsandmcp_call_tool); list those names in the workflow'stoolswhen steps need MCP. See Tools → MCP Integration.permissions— runtime tool-call gate; see Permissions.bash— run-level default for the SDK-injected Bash tool sandbox. Workflow-levelworkflow.bashwins for planner/worker/fixer sessions;orchestrator.bashwins for the orchestrator session. Defaults are network off, Python on, JavaScript on, all inside explicit mounts.runId— optional durable run identity. If omitted, one is generated. Reusing a terminalrunIdvalidates persisted state and replays the result.timeout— wall-clock cap. Accepts a duration string ('10m','2h') or milliseconds. The run is cancelled and markedRunFailedif it elapses.signal— anAbortSignalfor external cancellation. Same outcome astimeoutbut on demand.label/tags— a human-readable run label (overrides the definition'slabel, which defaults to the workflow id) and free-form tags for filtering runs.trigger— how the run started,{ kind, source?, from?, label? }, for trace readers such as LittleDB: for example{ kind: "api", source: "POST /loans" }. It is recorded astriggeron the run's ownRunStartedevent; a sub-run recordsparentRunIdinstead. A workflow a harness tool call runs gets{ kind: "harness", sessionId, turnId, toolCallId }fromasHarnessWorkflow. A run without one started from your own code.progress— callback invoked with{ currentStep?, lastEvent? }after each recordedStepScheduled,StepAttemptStarted,StepFailed, andStepCompletedevent.workflowVersionReuseStrategy—"planner_reviewed"(default) reuses the newestWorkflowVersionthat completed a run of the same workflow. When the planner would read nothing new since it planned that version or last reviewed and kept it (same definition, tools, model slots, output contract and input shape; only input values differ), the run keeps that version without a planner call. Otherwise the planner is shown it and keeps it, adapts it, or drafts a fresh one."planner_reviews_each_run"shows the planner that version on every run; use it when input values change what the plan should be, such as an input that carries instructions ({ task: "..." })."always_fresh"always plans from scratch. This overrides the definition's setting.result.plansays which happened.maxAttempts— default retry budget for step attempts unless a step declares its own retry policy.maxOuterCycles— default1(single-cycle). Set to> 1to engage the outer loop, which callsplanner.supervise()after each cycle. See Durability for the outer-loop resume model.outerLoopId— resume an existing outer-loop session by ID. If omitted andmaxOuterCycles > 1, a newouterLoopIdis generated. A concurrent call with the sameouterLoopIdand data directory waits for the running one, then returns what it committed.orchestrator,maxConcurrentSubRuns(deprecated,runOrchestrationonly) — the orchestrator harness config and the maximum sub-workflow runs it may have in flight (default10). On the Harness surface,workflowBudgets.maxConcurrentWorkflowRunsplays this role.
Bash sandbox
ctx.bash is powered by just-bash, not the host shell. Each role sees explicit mounts only: Memory Stores, Run Scratch, and Skills. The default cwd is the role's writable /mnt/scratch/own/ when available. Replay returns recorded Bash tool output; it does not re-execute commands or reconstruct live shell state.
await runWorkflow(summarizeTicket, input, {
tools,
bash: {
network: {
allow: ["https://api.example.com/"],
methods: ["GET", "HEAD"],
denyPrivateRanges: true,
},
},
});Not run options
The planner and models belong to the workflow definition, not to a run:
planner— setplanner(or a fixedplan) ondefineWorkflow. Custom harness implementations are passed there.models— setmodelormodelsondefineWorkflow; there is no run-level model override.ai/codeRunner— the worker harness owns AI step andcode.runstep execution; there is no separate adapter or runner.
Errors From The Call
runWorkflow takes exactly one workflow. Passing an array throws a TypeError that points to asHarnessWorkflow + createHarness({ workflows }) (or the deprecated runOrchestration). runOrchestration throws a TypeError when workflows is not a non-empty array.
Return Shape
type RunResult<T = unknown> = {
runId: string;
workflowVersionId: string;
status: 'completed';
output: T;
plan: RunPlan;
usage: UsageTotals;
events: readonly EventEnvelope[];
artifacts: readonly ArtifactRef[];
};
type RunPlan = {
readonly kind: 'drafted' | 'kept' | 'adapted' | 'fixed';
readonly plannerCalled: boolean;
readonly workflowVersionId: string;
readonly previousWorkflowVersionId?: string; // for 'kept' and 'adapted'
readonly rationale?: string; // why an earlier plan was kept, adapted or replaced
};
type UsageTotals = {
readonly inputTokens: number;
readonly outputTokens: number;
readonly cachedInputTokens: number; // subset of inputTokens, not an addition to it
readonly reasoningTokens: number; // subset of outputTokens, billed at the output rate
readonly costUsd: number | null; // null = unpriced, NOT free
readonly pricedCalls: number;
readonly unpricedCalls: number;
};output is typed by the workflow's output schema. events are the durable event envelopes for this run, and artifacts are artifact refs whose payloads live in the world's artifact store and can be inspected with little events.
Which Plan Ran
plan says how the run got the plan it executed:
"drafted"— the planner wrote a new plan."kept"— an earlier plan ran unchanged.plannerCalledisfalsewhen it was kept because nothing the planner reads changed, andtruewhen the planner reviewed it and chose to keep it."adapted"— the planner changed an earlier plan."fixed"— the workflow's ownplanran. No planner model is called.
previousWorkflowVersionId names the earlier plan for "kept" and "adapted", and rationale gives the planner's reason (or the runtime's, when it kept the plan without asking). Read the steps a run executed with readWorkflowVersion:
import { localWorld, readWorkflowVersion } from "@little-stack/workflow";
const world = localWorld();
const result = await summarizeTicket.run(input, { world });
if (result.plan.plannerCalled) {
const { lwir } = await readWorkflowVersion(world, result.plan.workflowVersionId);
console.log(lwir.steps.map((step) => `${step.id}: ${step.uses}`));
}Grading Runs
A run can finish and still be wrong. Grade it with gradeRun, from a reviewer, a test or a checker:
import { gradeRun, listRunGrades } from "@little-stack/workflow";
await gradeRun(world, result.runId, {
verdict: "fail",
reason: "Declined a score of 616. The policy does not cover it, so it should be referred.",
gradedBy: "underwriting-review",
});A "fail" grade counts as a change to the plan that run executed. The next run of the workflow does not keep that plan without a planner call: the planner gets the plan back with the grade's reason, and adapts it, replaces it, or keeps it and says why in result.plan.rationale. After that review the plan is kept without a planner call again until something else changes or another run is graded "fail". A "pass" grade is recorded for the planner to read and changes nothing. With workflowVersionReuseStrategy: "always_fresh" every run is planned anew, so grades are not read.
listRunGrades(world, { runId }) or listRunGrades(world, { workflowVersionId }) returns the recorded grades, oldest first. Grades are stored under <dataDir>/run-grades/, outside the run's event log, so grading a run never changes its replay.
Usage And Cost
usage is computed when the run's event log is materialized, not while it executes: every recorded model call carries token counts plus the provider/model it ran on, and those are multiplied by the bundled model registry's USD-per-1M-token rates. Durable events themselves are token-only — a costUsd field on a recorded usage payload is rejected outright, so a run total can never be double-counted from a pre-computed figure.
costUsd is number | null, and the two zero-ish values mean different things:
0— a genuine zero-dollar total. Normally this means the run made no model calls.null— the run made model calls, but none of them had a registry entry, so no honest total exists. This is unpriced, not free.- a number — the sum over
pricedCalls. WhenunpricedCalls > 0the figure covers only the calls that could be priced, so treat it as a floor and reportunpricedCallsalongside it.
Cache hits are priced at the model's published cache-read rate when the registry has one, and at the full input rate otherwise — a deliberate over-estimate in preference to a fabricated discount.
usage covers every harness session recorded under this runId — the planner and all worker sessions. It does not roll up sub-workflow runs: an orchestrator's run_workflow / start_workflow spawns a sub-run with its own runId and its own event stream, and its usage lives in that sub-run's own result. To total a pipeline, sum the orchestrator's usage with each sub-run's (navigate to them via harness.tool_call.succeeded.payload.result.runId).
Per-step totals are available from MaterializedRunState.steps[stepPath].usage and from little report <run-id>. Per-session token rollups are in the individual harness.session.completed events — those are a convenience for session consumers and are deliberately not re-summed into the run total, because they restate model calls that were already counted individually.
Budget enforcement against these numbers is post-alpha; so is pre-run cost estimation. See the Roadmap.
Errors
The promise rejects on RunFailed. The rejection carries the structured failure:
import { isRunFailedError, runWorkflow } from "@little-stack/workflow";
try {
await runWorkflow(summarizeTicket, input);
} catch (err) {
if (isRunFailedError(err)) {
console.error(err.runId, err.failedStepPath, err.causeCode);
// outer-loop failures carry additional context:
console.error(err.outerLoopId, err.lastCycleOutput);
console.error(err.result?.events);
} else {
throw err;
}
}isRunFailedError(err) narrows err to RunFailedError. Prefer it to instanceof RunFailedError: it also recognizes the error from another copy of the package, such as a workflow folder loaded with loadWorkflow. RunFailedError includes runId, workflowVersionId, causeCode, optional failedStepPath, and a FailedRunResult when the SDK recorded a terminal run. Cause codes:
input_schema_error— input did not match the workflow's input schema.planner_validation_exhausted— the planner could not produce a valid LWIR document within its repair budget.capability_drift— the computed manifest for an orchestrator, planner, or worker harness session does not match the manifest stored when the run was first started. Drift is fail-closed: no execution resumes until the environment matches the original manifest. The error includes a structured diff of what changed.runtime_config_error— deterministic runtime configuration is invalid, such asmaxOuterCycles > 1withoutplanner.supervisedefined,orchestratorabsent whenworkflowsis an array, or anouterLoopIdresumed with a different workflow definition,maxOuterCyclesor input (outer_loop_input_mismatch).step_schema_error— a step or terminal workflow output did not match its schema.step_failed— a non-runtime-owned step failure exhausted retries.max_visits_exceeded— a step attempted to start itsmaxVisits + 1-th visit.failedStepPathidentifies the step. The failed run is preserved in Local World for inspection.timeout— the run exceededtimeout.cancelled— the run was aborted throughsignal.outer_loop_exhausted—supervisereturned{ kind: "continue" }on the final cycle.outerLoopIdandlastCycleOutputare populated onRunFailedErrorfor this code.
outerLoopId and lastCycleOutput are set only when causeCode === "outer_loop_exhausted". failedStepPath is set on max_visits_exceeded. All other fields are always present.
Budget enforcement (BudgetExhaustedError) arrives post-alpha (see the Roadmap). Tool-call permission denial, by contrast, ships in alpha — see Permissions.
Recoverable step failures (transient network, rate limits) are retried automatically inside the same run and never bubble up here unless the retry policy is exhausted.
Cancellation
Cancellation always produces a RunFailed with causeCode: 'cancelled'. The runtime stops scheduling new steps, lets in-flight step attempts complete or be killed by their own timeout, and finalizes the run record.
const controller = new AbortController();
setTimeout(() => controller.abort(), 5_000);
await runWorkflow(myWorkflow, input, { signal: controller.signal });Cancellation is propagated into any in-flight harness sessions (planner, orchestrator, or worker). See Durability for the crash and cancellation recovery model.
Permissions
The permissions option is a runtime, rule-based allow / deny / ask gate over tool calls (modelled on opencode's permission system). It ships in alpha; only budget enforcement is deferred.
await runWorkflow(myWorkflow, input, {
permissions: {
ruleset: [
{ tool: "delete_*", action: "deny" },
{ tool: "send_email", action: "ask" },
],
// Resolve an "ask" decision. Return true to allow, false to deny.
onAsk: async ({ tool, args }) => confirmWithOperator(tool, args),
},
});A rule's tool is a glob over the tool name: * stands for any run of characters, so "delete_*" gates delete_record and delete_user, "bash*" gates bash, and "*" matches every tool. The policy is opt-in: with no rules, every tool call is allowed. When rules are present, a matching deny short-circuits (most restrictive wins — an allow rule never overrides a matching deny), a matching ask requires approval via onAsk, and anything unmatched is allowed. In a headless run with no onAsk approver, an ask denies — the gate fails closed.
Streaming
Streaming responses are not a public alpha surface; use the progress callback for step-level progress. Streaming session events to runWorkflow callers is on the Roadmap.
Replay
Passing a previous terminal runId to runWorkflow replays durable state. Completed replays validate the persisted input, final output, output schema, artifact payload hashes, and artifact ownership before returning. Terminal failed replays require a RunStarted record, validate persisted input when present, validate completed-step artifact payload hashes and ownership, then reject with the original RunFailedError metadata.
To inspect an existing run's event log directly, use the CLI:
little events run_123User-invoked retry-from-step (little retry) and little fork are not part of the alpha CLI. Alpha supports only automatic retry while a run is executing, plus replay from the event log. See Durability for the replay-vs-retry semantics that alpha does support.