SDK API
The minimal authoring + runtime surface that ships with v0.1.0-alpha.
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
Alpha exposes the front door, tool registry, a one-shot runner, the Little Harness adapter (asHarnessWorkflow, loadWorkflow), cost reporting, harness primitives, and skill/memory configuration. Pipelines (chainWorkflows, transform), skill packs, plugins, and a long-lived runtime object are not part of the alpha API (see the Roadmap). createRuntime is not exported.
The package requires Node.js 22+ and takes ai (^7.0.0) as a peer dependency. It has these entry points:
| Import | Exports |
|---|---|
@little-stack/workflow | defineWorkflow, model, skill, output, createToolRegistry, runWorkflow, runOrchestration (deprecated), localWorld, readWorkflowVersion, gradeRun, listRunGrades, replayRun, RunFailedError, isRunFailedError, asHarnessWorkflow, versionedHarnessWorkflow, loadWorkflow, dynamicWorkflows, agentWorkflow, and their types |
@little-stack/workflow/lwir | validateLwir, assertValidLwir, LwirValidationError, registerWorkflowVersion, and the LWIR types |
@little-stack/workflow/eval | Eval-set validation, hashing and storage |
@little-stack/workflow/scaffold | The generators behind little setup and little init |
@little-stack/workflow/internals | Unstable, no semver guarantees: the lower-level createLittleWorkflow, the runtime (runWorkflowCycle, executeWorkflowVersion), world storage functions (listEvents, materializeRunState, ...), harness plumbing (workflowHarness, createWorkflowHarness, Harness), manifests, pricing and run reports |
Imports below come from @little-stack/workflow unless they name another entry point.
For the implementation contract behind these exports, see SDK Implementation Spec.
Tool Registry
Tools are registered using AI SDK's tool() helper and collected in a ToolRegistry. The WorkflowDefinition references them by name; the compiler reads descriptors to build the WorkflowVersion lock; the runtime reads handlers at step lease.
import { createToolRegistry } from "@little-stack/workflow";
import { tool } from "ai";
import { z } from "zod";
const tools = createToolRegistry({
parsePdf: tool({
description: "Parse a PDF file and return structured text.",
inputSchema: z.object({ url: z.string() }),
outputSchema: z.object({ text: z.string() }),
execute: async ({ url }) => { /* … */ return { text: "…" }; },
}),
lookupCustomer: tool({
description: "Look up a customer account by id.",
inputSchema: z.object({ accountId: z.string() }),
execute: async ({ accountId }) => ({ accountId, plan: "enterprise" }),
}),
});type ToolRegistry = {
readonly get: (id: string) => AiSdkTool | undefined;
readonly has: (id: string) => boolean;
readonly list: () => readonly string[]; // sorted alphabetically
readonly snapshotForManifest: () => ToolRegistrySnapshot; // used in manifests + WorkflowVersion lock
readonly attachMcpTools: (
mcpTools: Record<string, AiSdkTool>,
options?: { prefix?: string }
) => void;
readonly toRecord: () => Record<string, AiSdkTool>;
};
export function createToolRegistry(
initial?: Record<string, AiSdkTool>,
): ToolRegistry;The registry is mutable (new tools may be registered at any time) but immutable per-entry — re-registering an existing name with a different descriptor throws.
MCP integration
The preferred path is declarative MCP on runWorkflow(workflow, input, { mcp }). Little Workflow asks Little Harness to resolve configured servers into ordinary gateway tools and guide skills.
Manual attachment remains available for callers that already manage MCP client lifetime. The MCP client lives in AI SDK 7's companion package @ai-sdk/mcp (not the ai core), so install it explicitly: pnpm add @ai-sdk/mcp.
import { createMCPClient } from "@ai-sdk/mcp";
import { Experimental_StdioMCPTransport as StdioMCPTransport } from "@ai-sdk/mcp/mcp-stdio";
const atlasClient = await createMCPClient({
transport: new StdioMCPTransport({
command: "node",
args: ["./mcp-servers/atlas-mcp.mjs"],
}),
});
const atlasTools = await atlasClient.tools();
tools.attachMcpTools(atlasTools, { prefix: "atlas" });
// registry now includes: parsePdf, lookupCustomer, atlas.search, atlas.fetch, …
await runWorkflow(workflow, input, { tools });
await atlasClient.close();The declarative gateway tools are normally mcp_list_tools and mcp_call_tool; include those names in the workflow's tools when worker steps need MCP access.
Authoring
defineWorkflow(definition);
model(aiSdkModel, metadata?);
skill(pathOrUrl, options?);
asHarnessWorkflow(workflow, { definitionIdentity, executionMode?, ...runDefaults });
loadWorkflow(folder, options?);defineWorkflow
Defines a workflow. Plain input/output schemas, one model or several models, an optional planner, a fixed plan, and the workflow's tools are normalized into the shape the runtime runs (model slots, a planner config, an output mode and the declared tool names). The schema types flow through, so runWorkflow(workflow, input).output is typed.
import { defineWorkflow, runWorkflow } from "@little-stack/workflow";
import { anthropic } from "@ai-sdk/anthropic";
import { z } from "zod";
const reviewApplicants = defineWorkflow({
id: "applicants.review",
input: z.object({ role: z.string(), applicants: z.array(z.object({ id: z.string(), resume: z.string() })) }),
output: z.array(z.object({ id: z.string(), score: z.number(), decision: z.enum(["advance", "reject", "maybe"]) })),
model: anthropic("claude-sonnet-4-6"),
});
const result = await runWorkflow(reviewApplicants, { role: "Staff Engineer", applicants });
result.output; // typed — no castThe full config, with several model slots, a planner config, a sandbox policy and memory mounts:
import { defineWorkflow, model, skill } from "@little-stack/workflow";
import { openai } from "@ai-sdk/openai";
const candidateReview = defineWorkflow({
// Identity
id: "candidate.review",
description: "Review one candidate against a job description.",
// I/O schemas (Zod, Standard Schema or JSON Schema)
input: z.object({
jobDescription: z.string(),
candidate: z.object({ id: z.string(), resume: z.string() }),
}),
output: z.object({
passed: z.boolean(),
reasoning: z.string(),
}),
// Execution model slots (planner chooses among these in LWIR)
models: [
model(openai("gpt-4o-mini")),
model(openai("gpt-4o-mini"), {
description: "Long-document summarizer.",
}),
// slot ids: "gpt-4o-mini" (first wins), "gpt-4o-mini-<animal>" (second)
],
// Planner — optional; defaults to the first model. Its harness defaults to the SDK workflowHarness
planner: {
model: openai("gpt-5"), // raw AI SDK model or ModelSlot
system: "You design candidate-review workflows.",
skills: [
skill("./skills/writing-loop-lwir/"),
skill("./skills/output-schema-validation.md"),
],
// tools: optional extra tools beyond SDK-injected (bash, memory read)
// supervise: optional outer-loop supervisor (see Outer Loop section)
},
// Worker — optional; defaults to the SDK workflowHarness.
// worker: { harness: customHarness, skills: [...] },
// Bash tool sandbox — optional; defaults shown in the field reference below
bash: {
network: {
allow: ["https://api.example.com/"],
methods: ["GET", "HEAD"],
},
},
// Names of tools the run supplies (runWorkflow(..., { tools })); or AI SDK tools keyed by name
tools: ["parsePdf", "lookupCustomer"],
// Memory mount config — optional; defaults shown
memory: {
workflow: "rw", // planner rw to /mnt/memory/workflow/
org: "ro", // ro to /mnt/memory/org/
// attach: [{ id: "peer-workflow-id", mode: "ro" }],
},
});defineWorkflow options
| Field | Required | Type | Notes |
|---|---|---|---|
id | yes | string | Stable identifier; hashed into WorkflowDefinitionHash |
description | recommended | string | Shown to the planner, and used as the tool description by asHarnessWorkflow |
label | no | string | Human-readable run label; defaults to the workflow id. RunWorkflowOptions.label overrides it per run |
input | recommended | Zod, Standard Schema, or JSON Schema | Validated at run start; must fit the LWIR schema subset |
suggestedInput | no | Zod or JSON Schema | A schema hint shown to the planner; not used to validate input |
output | recommended | schema or OutputMode | Plain schema (array/object/text inferred) or an explicit output.* mode; see Output Modes |
model | one of model, models, plan | AiSdkModel or ModelSlot | One execution model |
models | one of model, models, plan | ModelSlot[] (≥ 1) | Execution model options the planner can reference in LWIR step.with.model. Not with model |
policy | no | string | Rules the runtime adds, word for word, to the system prompt of every ai.generate step. Shown to the planner; a change sends a saved plan back for review |
planner | no | AiSdkModel, ModelSlot, or PlannerConfig | A bare model is lifted into a PlannerConfig; defaults to the first model |
plan | no | { steps } or LwirWorkflow | A fixed plan that replaces the planner; no model needed unless a step calls one. Not with planner |
tools | no | Record<string, AiSdkTool>, ToolRegistry, or string[] | Tools the plan may call. Inline tools are wired into runs automatically; named tools must be supplied by the run |
toolSelection | no | "planner_selected" | "all" | "explicit_only" | Recorded in the capability manifest; default "planner_selected". "explicit_only" keeps the plan from referencing the declared tools |
workflowVersionReuseStrategy | no | "planner_reviewed" | "planner_reviews_each_run" | "always_fresh" | Default "planner_reviewed": a prior compiled version is kept without a planner call when nothing the planner reads changed, and otherwise shown to the planner, which may reuse it. "planner_reviews_each_run" shows it to the planner on every run, for inputs whose values change the plan. "always_fresh" always plans anew |
worker | no | WorkerConfig | Defaults to the SDK workflowHarness |
memory | no | MemoryConfig | Defaults to { workflow: "rw", org: "ro" } |
bash | no | BashCapabilities | Role-scoped Bash tool sandbox; network is off by default |
Returns a DefinedWorkflow<TInput, TOutput>: the workflow definition, whose input/output schema types flow through to results, plus a typed .run(input, options?) method. workflow.run(input, options) and runWorkflow(workflow, input, options) are the same call; both return RunResult<TOutput> with a typed .output (no cast).
A fixed plan, which needs no model:
const input = z.object({ value: z.string() });
const echo = defineWorkflow({
id: "starter_echo",
input,
tools: { echo: tool({ inputSchema: input, execute: async ({ value }) => ({ echoed: value }) }) },
plan: {
steps: [
{ id: "echo", uses: "tool.call", with: { tool: "echo", args: { value: "{{ input.value }}" } }, output: { mode: "json", schema: true } },
],
},
});PlannerConfig
type PlannerConfig = {
readonly model: AiSdkModel | ModelSlot; // raw AI SDK model OK; SDK auto-wraps
readonly harness?: Harness; // defaults to workflowHarness
readonly system?: string; // merged with SDK role addendum
readonly skills?: readonly Skill[];
readonly skillMaxRisk?: SkillRiskLevel; // max accepted remote-skill risk for this role
readonly skillOidcToken?: SkillOidcToken; // token for the Skills API risk audit
readonly tools?: ToolSet; // extra tools beyond SDK-injected
readonly supervise?: ( // outer-loop supervisor (optional)
state: SuperviseOuterLoopState
) => Promise<SuperviseDecision>;
};If planner.model is a raw AI SDK model (not a ModelSlot), the SDK wraps it via model() with auto-derived slot id. The resulting slot is NOT added to WorkflowDefinition.models.
WorkerConfig
type WorkerConfig = {
readonly harness?: Harness; // defaults to workflowHarness
readonly skills?: readonly Skill[];
readonly skillMaxRisk?: SkillRiskLevel;
readonly skillOidcToken?: SkillOidcToken;
readonly tools?: ToolSet; // merged with the workflow's tools
};BashCapabilities
The Bash tool is SDK-injected into every harness session as ctx.bash. It is not a developer Tool Registry entry. Alpha runs it through the just-bash sandbox with explicit Memory Store, Run Scratch, and Skill mounts.
type BashCapabilities = {
readonly network?:
| false
| true
| {
readonly allow?: readonly string[]; // URL prefixes
readonly methods?: readonly string[]; // default for `true`: ["GET", "HEAD"]
readonly maxRedirects?: number;
readonly timeoutMs?: number;
readonly maxResponseSize?: number;
readonly denyPrivateRanges?: boolean; // default true
readonly dangerouslyAllowFullInternetAccess?: boolean;
};
readonly python?: boolean; // default true, inside the sandbox
readonly javascript?: boolean; // default true, inside the sandbox
};Defaults: network: false, python: true, javascript: true. network: true allows https:// with GET/HEAD and denies private ranges. For production-like runs, prefer the object form with explicit allow prefixes.
The effective Bash capabilities are part of the WorkflowDefinitionHash, OrchestrationRequest.capabilityManifest, and harness session manifests. Changing them produces a new compiled workflow identity and fails closed on resume if an in-flight session would see a different sandbox.
MemoryConfig
type MemoryConfig = {
readonly workflow?: "rw" | "ro" | "none"; // default: "rw"
readonly org?: "rw" | "ro" | "none"; // default: "ro"
readonly attach?: ReadonlyArray<{ id: string; mode: "ro" }>;
};model() factory
Auto-derives slot ids from the model name. Collisions within a workflow get a deterministic animal-suffix disambiguator.
export function model(
aiSdkModel: AiSdkModel,
metadata?: ModelSelectionMetadata,
): ModelSlot;
export type ModelSelectionMetadata = {
readonly id?: string; // explicit override; must be unique in the workflow
readonly description?: string; // auto-filled from bundled registry if model is known
};Slot id derivation:
model(openai("gpt-4o-mini"))→ slot id"gpt-4o-mini"- Second
model(openai("gpt-4o-mini"), { description: "..." })in the same workflow →"gpt-4o-mini-<animal>"(deterministic frommodelId + description) - Explicit:
model(openai("gpt-4o-mini"), { id: "fast" })→"fast"
The bundled model registry (packages/little-workflow/src/model-registry.json) provides default descriptions, pricing (USD per 1M tokens, with an optional cachedInput cache-read rate), and contextWindow for known models. For an unknown model getModelInfoFor() returns undefined, so description is left unset unless you supply one.
Registry pricing is not written into events — durable events are token-only. It is applied at materialization time, where a model with no registry entry produces costUsd: null for that call and increments usage.unpricedCalls. null means unpriced, never free; an unpriced call is never folded in as 0. See Run A Workflow → Usage And Cost.
skill() factory
export function skill(pathOrUrl: string, options?: RemoteSkillOptions): Skill;
type Skill = {
readonly kind: "skill";
readonly source: string;
readonly name?: string;
readonly frontmatterHash?: string;
readonly remote?: RemoteSkillIdentity;
} & RemoteSkillOptions;
type RemoteSkillOptions = {
readonly skills?: readonly string[];
readonly skillMaxRisk?: SkillRiskLevel;
readonly skillRisk?: Record<string, SkillRiskLevel>;
readonly auth?: SkillGitAuth;
};
type SkillRiskLevel = "NONE" | "LOW" | "MEDIUM" | "HIGH" | "CRITICAL";
type SkillGitAuth = {
readonly type: "bearer";
readonly token: string | undefined;
};Accepts a local path (directory or single .md file) or an explicit Git URL. The runtime materializes the skill read-only under .agents/skills/<name>/ from the role/root working directory before the role session starts.
Parameters:
Local directory, local .md file, or explicit Git URL. Remote skill URLs must be explicit Git sources; shorthands such as org/repo, github:org/repo, and gitlab:org/repo are rejected.
Remote Git options used when pathOrUrl is a remote source.
Selected remote skill names, matching repeated --skill usage in the Skills CLI. If omitted, every non-internal skill in the repository is installed.
Maximum accepted risk for all selected skills from this source.
Per-skill maximum risk overrides for selected remote skills.
Explicit Git authentication. Only bearer tokens are supported.
Returns a lazy Skill reference. Little Workflow resolves the reference for the configured role, injects skill metadata into that role's harness prompt, and stages the full body for on-demand reading.
skill("./skills/writing-loop-lwir/"); // directory form
skill("./skills/output-schema-validation.md"); // single-file
skill("https://github.com/anthropics/skills", {
skills: ["frontend-design"],
}); // selected remote skill namesRemote skill URLs must be explicit Git sources, including GitHub, GitLab tree URLs, generic HTTPS Git URLs, SSH URLs, and scp-style Git URLs. Shorthands such as org/repo, github:org/repo, and gitlab:org/repo are rejected. If skills is omitted, every non-internal skill in the source is installed. The materialization cache is keyed by resolved commit SHA; include a commit SHA or ref in the Git URL/source options when you need a specific version.
Remote Git skill loading is best-effort during role startup: unavailable repositories, missing selected skills, and remote audit failures skip that remote source and add a warning to the harness session event. Local skill failures still fail the run before the role session starts.
workflowHarness
The default Harness, exported from @little-stack/workflow/internals. Wraps AI SDK's agent loop with Little Workflow concerns: system prompt assembly, tool exposure, per-turn event recording, durability checks, memory/scratch/skill mounts, and cancellation. Little Workflow uses it automatically when planner.harness, worker.harness, or orchestrator.harness is omitted; pass a custom harness only when you need to substitute the runtime.
dynamicWorkflows()
Enables model-authored one-shot plans and agent-defined workflows on a Little Harness agent. Pass the result to createHarness({ dynamicWorkflows }). The helper returns { enabled: true, factory, exclude?, limits?, models?, define? }, injecting the plan-lowering factory that turns an agent-authored step-DAG into a frozen LWIR workflow and runs it once. It lives in @little-stack/workflow (not @little-stack/harness) because the lowering factory depends on Little Workflow internals, and @little-stack/harness cannot import them without a circular dependency — so a bare true on the harness cannot work; dynamicWorkflows() is the injection point.
import { createHarness } from "@little-stack/harness";
import { dynamicWorkflows } from "@little-stack/workflow";
createHarness({
host,
model,
tools: { docsSearch },
dynamicWorkflows: dynamicWorkflows({
exclude: ["dangerousTool"], // narrow below the agent's snapshot
limits: { maxSteps: 6, maxRuntimeMs: 60_000 }, // defaults: 8 / 120000
}),
});The agent gets run_ad_hoc_plan and search_authored_plans tools. A plan may only reference capabilities the agent already has (enforced as capability_not_allowed), is frozen + replay-proof, runs inline, and is persisted for cross-run recall.
It also gets define_workflow (turn off with define: false): the agent describes recurring work (id, description, input and output JSON Schemas, a subset of its tools, a policy) and the factory builds it as a defineWorkflow workflow whose planner is the agent's model and whose model slots are default plus any models. From the next turn on, in every session on the same host dataDir, it is a tool named after its id; its runs share one store, <dataDir>/agent-workflows, so the plan is kept across sessions. See the Little Harness guide, Letting an agent author its own workflow, for the full behavior.
Two more options put the workflows an agent defines under the host's control:
registry—{ defined?, resolve? }hooks (AgentWorkflowRegistryfrom@little-stack/harness).defined(record, { sessionId, turnId })runs after a revision is stored and may return anotefor the agent andmetadatafor the runrunWithstarts;resolve(records, { sessionId, turnId })runs when a turn starts and returns the workflows that turn offers. Without one, every stored definition is offered, in every session.world— aWorld, or a function of aDynamicWorkflowWorldContext(kind,workflowId,handle,runId,dataDir,sessionId,turnId,toolCallId, and for a defined workflow itsdefinition,revisionand registrymetadata) that returns one, for agent-defined workflow and one-shot plan runs. Default:localWorldin the harness's stores. Both types are exported.
dynamicWorkflows({
registry: { resolve: (records) => records.filter((record) => approved.has(record.handle)) },
world: (run) => tracingWorld(localWorld({ dataDir: run.dataDir }), run),
});Output Modes
Workflow and step outputs can be plain text or schema-constrained data:
type OutputMode<T> =
| { kind: "text" }
| { kind: "object"; schema: Schema<T> }
| { kind: "array"; element: Schema<T> }
| { kind: "choice"; values: readonly string[] }
| { kind: "json"; schema?: Schema<T> };Runtime
runWorkflow
function runWorkflow<TWorkflow extends WorkflowDefinition>(
workflow: TWorkflow,
input: InferWorkflowInput<TWorkflow>,
options?: RunWorkflowOptions,
): Promise<RunResult<InferWorkflowOutput<TWorkflow>>>;import { localWorld, runWorkflow } from "@little-stack/workflow";
const result = await runWorkflow(reviewApplicants, { role: "Staff Engineer", applicants });
// world → localWorld(); inline tools wired in
const custom = await runWorkflow(candidateReview, { jobDescription, candidate }, {
world: localWorld({ dataDir: ".little-workflow-reviews" }),
tools,
timeout: "10m",
});
// The same call, via the defined workflow's own method:
const same = await reviewApplicants.run({ role: "Staff Engineer", applicants });runWorkflow runs one workflow. world defaults to localWorld() and tools to the tools declared inline on the workflow, so a typical run needs no plumbing. workflow.run(input, options?) is identical. Both return RunResult<TOutput> with a typed .output, .runId, .usage and .plan (whether the plan was drafted, kept, adapted or fixed, and whether the planner model was called) — see RunResult for the full shape. A failed run rejects with a RunFailedError; see Error Types.
Passing an array throws a TypeError: compose several workflows with asHarnessWorkflow + createHarness({ workflows }), or use the deprecated runOrchestration.
RunWorkflowOptions
type RunWorkflowOptions = {
readonly world?: World; // default localWorld()
readonly tools?: ToolRegistry; // default: the workflow's inline tools
readonly mcp?: HarnessMcpConfig; // declarative MCP servers → gateway tools
readonly bash?: BashCapabilities; // run-level Bash sandbox default
readonly permissions?: ToolPermissions; // allow/deny/ask gate over tool calls
// Outer loop
readonly maxOuterCycles?: number; // default 1 (no outer loop); requires planner.supervise when > 1
readonly outerLoopId?: string; // resume an existing outer-loop session
// Execution
readonly runId?: RunId;
readonly signal?: AbortSignal;
readonly timeout?: number | string;
readonly maxAttempts?: number;
// Labels, progress, reuse
readonly label?: string; // overrides the definition's label (default: workflow id)
readonly tags?: readonly string[]; // free-form tags for filtering runs
readonly trigger?: RunTrigger; // how the run started, on its RunStarted event
readonly progress?: (event: WorkflowRunProgressEvent) => void | Promise<void>; // { currentStep?, lastEvent? }
readonly workflowVersionReuseStrategy?: WorkflowVersionReuseStrategy; // overrides the definition's
};
type RunTrigger = {
readonly kind: string; // "api", "schedule", "webhook", "harness", or your own word
readonly source?: string; // a route, a schedule id, a harness slug
readonly from?: string; // who asked, as the channel names them
readonly label?: string; // a short label for people
readonly sessionId?: string; // kind "harness": the session, turn and tool call that started the run
readonly turnId?: string;
readonly toolCallId?: string;
};See Run A Workflow → Options for what each field does. The planner and models are not run options: set them on defineWorkflow.
runOrchestration (deprecated)
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. For batch work, plan once + run many.",
skills: [skill("./skills/batch-fan-out/")],
},
maxConcurrentSubRuns: 10, // default 10
});RunOrchestrationOptions is RunWorkflowOptions without the outer-loop fields, plus workflows (a non-empty array), input, orchestrator (required), and maxConcurrentSubRuns.
Deprecated. The orchestrator composition surface is being retired in favour of
asHarnessWorkflow+createHarness({ workflows }).runWorkflowis not deprecated. See Compose With Little Harness.
OrchestratorConfig
Deprecated. Part of the orchestrator composition surface. Compose with
asHarnessWorkflow+createHarness({ workflows })instead (guide); scheduled to be unexported in the next minor.createOrchestratorToolsandworkflowSnapshotsForOrchestratorare deprecated with it.
type OrchestratorConfig = {
readonly model: AiSdkModel | ModelSlot;
readonly harness?: Harness; // defaults to workflowHarness
readonly system?: string;
readonly skills?: readonly Skill[];
readonly skillMaxRisk?: SkillRiskLevel;
readonly skillOidcToken?: SkillOidcToken;
readonly tools?: ToolSet;
readonly bash?: BashCapabilities; // orchestrator-session override
};localWorld options
type LocalWorldOptions = {
dataDir?: string;
maxConcurrentSteps?: number;
};
localWorld({ dataDir: ".little-workflow", maxConcurrentSteps: 8 });virtualWorld options
virtualWorld({ fs, dataDir: "/workflows", maxConcurrentSteps: 8 });fs is a required just-bash IFileSystem, also exposed by the World port as
readonly files?: IFileSystem. dataDir defaults to / and uses normalized
POSIX paths. Run files use that filesystem, including events, artifacts, memory,
scratch and kept plans. Event receipt, artifact-reference and sequence rules are
shared with localWorld().
Only one process may own the filesystem. Worlds over the same filesystem object
and normalized root share locks; different objects remain independent. Separate
filesystem wrappers do not share identity. Persistence and atomic publication
depend on the filesystem; reopening the same object does not prove crash
durability. Folder and remote skills are refused on this World. See
Virtual World.
maxConcurrentSteps caps total in-flight step attempts across all parallel groups and serial steps in a run. Effective concurrency = min(group.maxConcurrency, world.maxConcurrentSteps - in-flight). Default undefined (no cap).
readWorkflowVersion
function readWorkflowVersion(world: World, workflowVersionId: string): Promise<StoredWorkflowPlan>;
type StoredWorkflowPlan = {
readonly workflowVersionId: string;
readonly workflowId: string;
readonly lwir: LwirWorkflow; // the plan; its steps are what the run executed
};Reads the plan a run executed from the world the run used. Pass result.workflowVersionId or result.plan.workflowVersionId. An id the world does not hold rejects with an error naming the directory it looked in. Validate or inspect the LWIR with @little-stack/workflow/lwir.
gradeRun and listRunGrades
function gradeRun(world: World, runId: string, grade: GradeRunInput): Promise<RunGrade>;
function listRunGrades(
world: World,
filter?: { runId?: string; workflowVersionId?: string },
): Promise<readonly RunGrade[]>;
type GradeRunInput = {
readonly verdict: "pass" | "fail";
readonly reason?: string; // the planner reads it when it reviews the plan
readonly gradedBy?: string;
};
type RunGrade = GradeRunInput & {
readonly gradeId: string;
readonly runId: string;
readonly workflowId: string;
readonly workflowVersionId: string; // the plan the graded run executed
readonly gradedAt: string;
};gradeRun records whether a finished run did its job. A "fail" grade sends the plan that run executed back to the planner on the next run, with the reason, instead of keeping it without a planner call; see Grading Runs. It rejects a run id the world does not hold and a run that never got a plan. listRunGrades returns grades oldest first.
Little Harness Composition
The supported way to compose workflows. See Compose With Little Harness for the guide.
asHarnessWorkflow
function asHarnessWorkflow(
workflow: WorkflowDefinition<any, any>, // a defineWorkflow result, no cast
options: HarnessWorkflowAdapterOptions,
): HarnessWorkflow; // pass to createHarness({ workflows }) from little-harness
type HarnessWorkflowAdapterOptions = Omit<RunWorkflowOptions, "trigger"> & { // run defaults: tools, mcp, timeout, world, …
readonly definitionIdentity: string | { readonly notApplicable: true; readonly reason?: string }; // required
readonly executionMode?: "inline" | "durable"; // default "inline"
readonly allowUntypedInput?: boolean;
readonly allowLossyStandardSchema?: boolean;
};Throws when definitionIdentity is missing. The workflow becomes one tool named from its id (billing.refund → billing_refund). A call returns { status, runId, outputSummary?, outputPath? }, or { status, runId, causeCode, message, outputSummary? } on failure or cancellation. outputSummary is a deterministic rendering of the output capped at 4,096 characters, not the typed output object. Runs land in <dataDir>/sessions/<session>/workflows unless you pass world. Each run records { kind: "harness", sessionId, turnId, toolCallId }, the tool call that started it, as its trigger.
versionedHarnessWorkflow
function versionedHarnessWorkflow<C>(
source: WorkflowVersionSource<C>, // e.g. LittleDB's db.managedWorkflow(...)
build: (content: C) => WorkflowDefinition<any, any>,
options?: VersionedHarnessWorkflowOptions, // asHarnessWorkflow's options; definitionIdentity optional
): HarnessWorkflow;
type WorkflowVersionSource<C> = {
readonly bootstrap: C;
prepare(options: { stickyKey?: string }): Promise<{ content: C; world(inner: World): World }>;
};Like asHarnessWorkflow, but builds the workflow per run from the version the session runs, so
changes made outside the code (LittleDB's step prompt and model changes) reach a workflow the
harness calls. Each run calls prepare with the harness session as the sticky key, builds from
content (cached per content) and runs in world(inner), where inner is options.world or the
harness's Local World. The tool's id, description and input schema come from build(bootstrap);
definitionIdentity defaults to its hash.
agentWorkflow
function agentWorkflow(
definition: AgentWorkflowDefinition, // what define_workflow wrote, or a version LittleDB proposes
bindings: { tools: Record<string, unknown>; models: Record<string, unknown>; planner: unknown },
): DefinedWorkflow;Builds an agent-made workflow so it runs outside its harness (a test, a script, an eval worker),
with the same tools and model slots the harness gives it. Throws when the definition names a tool
bindings.tools lacks, or a schema is not usable JSON Schema.
loadWorkflow
function loadWorkflow<TInput = unknown, TOutput = unknown>(
folder: string | URL, // contains workflow.ts (+ optional instructions.md, tools/, skills/)
options?: {
readonly workspaceRoot?: string | URL; // default: nearest little-workflow.json, then package.json
readonly executionMode?: "inline" | "durable";
readonly allowUntypedInput?: boolean;
readonly allowLossyStandardSchema?: boolean;
readonly sourceIdentity?: string; // required for durable mode with dynamic/unresolved imports
},
): Promise<LoadedWorkflow<TInput, TOutput>>;A LoadedWorkflow is a HarnessWorkflow with a derived definitionIdentity, plus workflow, tools, source, and run(input, options?) for standalone runs.
Cost Reporting
RunResult.usage carries real dollars (costUsd), and the same figures are available per step from the event log:
import { localWorld } from "@little-stack/workflow";
import {
formatRunReport,
materializeRunState,
priceModelCall,
runReport,
} from "@little-stack/workflow/internals";
const report = runReport(await materializeRunState(localWorld(), result.runId));
console.log(formatRunReport(report)); // the `little report --table` rendering
priceModelCall(
{ provider: "openai.responses", modelId: "gpt-4o-mini" },
{ inputTokens: 1_000, cachedInputTokens: 0, outputTokens: 200 },
).costUsd; // number, or null when the model is not in the registryRates come from the bundled model registry. costUsd: null means unpriced, never free.
Harness Contract
Custom harnesses implement one method. The Harness types come from @little-stack/workflow/internals:
type Harness = {
readonly harnessId?: string; // recorded in session manifests
run(task: HarnessTask, ctx: HarnessContext): Promise<HarnessResult>;
};
type HarnessTask =
| { readonly kind: "plan"; readonly workflowSnapshot: WorkflowDefinitionSnapshot; readonly input: unknown; readonly outerLoopContext?: OuterLoopContext }
| { readonly kind: "orchestrate"; readonly available: readonly WorkflowDefinitionSnapshot[]; readonly input: unknown }
| { readonly kind: "execute_step"; readonly step: LwirStep; readonly stepInput: unknown; readonly stepContext: { stepPath: string; visitIndex: number } }
| { readonly kind: "fix_step"; readonly step: LwirStep; readonly stepInput: unknown; readonly originalAttemptError: ErrorEnvelope; readonly priorFixAttempts: number };
type HarnessContext = {
readonly scope: HarnessScope;
readonly session: { // run id, role, task kind, manifest + hash, parent run id
readonly runId: string;
readonly role: HarnessRole;
readonly task: { readonly kind: HarnessTask["kind"] };
readonly manifest: unknown;
readonly manifestHash: string;
readonly parentRunId?: string;
};
readonly model: ResolvedModel;
readonly system?: string;
readonly tools: ToolSet;
readonly memoryMounts: readonly MemoryMount[];
readonly scratchMounts: readonly ScratchMount[];
readonly skills: readonly SkillDescriptor[];
readonly mounts: readonly HarnessRuntimeMount[];
readonly bashCapabilities?: WorkflowBashCapabilities;
readonly durability: WorkflowHarnessDurabilitySink;
readonly trace?: WorkflowHarnessTraceSink;
readonly abortSignal: AbortSignal;
readonly permissions?: ToolPermissions; // the run's allow/deny/ask policy, when set
readonly bash?: BashTool;
readonly recorder?: HarnessEventRecorder;
};
type HarnessResult =
| { readonly kind: "plan"; readonly lwir: unknown }
| { readonly kind: "orchestrate"; readonly output: unknown }
| { readonly kind: "execute_step"; readonly output: unknown; readonly artifactRefs: readonly ArtifactRef[] }
| { readonly kind: "fix_step"; readonly output: unknown; readonly fixedSource: string; readonly attempts: number }
| { readonly kind: "delegate_to_default" }; // fall back to workflowHarness for this task kindThe SDK merges role-specific tools (bash, plan_workflow, run_workflow, start_workflow for orchestrator) into ctx.tools before calling the harness. Custom harnesses do not need workflow domain knowledge.
Manifest Types
Each harness session writes a manifest to its harness.session.started event for drift detection on resume.
type OrchestratorManifest = {
readonly harnessId: string;
readonly orchestratorModelSlotId: string;
readonly systemPromptHash: string;
readonly skillsHash: string;
readonly availableWorkflows: ReadonlyArray<{ readonly id: string; readonly definitionHash: string }>;
readonly globalToolsHash: string;
readonly memoryStoreIds: ReadonlyArray<string>;
readonly bashCapabilitiesHash: string;
readonly maxConcurrentSubRuns: number;
};
type PlannerManifest = {
readonly harnessId: string;
readonly plannerModelSlotId: string;
readonly systemPromptHash: string;
readonly skillsHash: string;
readonly workflowDefinitionHash: string;
readonly globalToolsHash: string;
readonly memoryStoreIds: ReadonlyArray<string>;
readonly bashCapabilitiesHash: string;
readonly outerLoopContextHash?: string;
};
type WorkerManifest = {
readonly harnessId: string;
readonly workflowDefinitionHash: string;
readonly workflowVersionId: string;
readonly stepPath: string;
readonly stepConfigHash: string;
readonly modelSlotId?: string;
readonly systemPromptHash?: string;
readonly skillsHash: string;
readonly allowedToolsHash: string;
readonly memoryStoreIds: ReadonlyArray<string>;
readonly bashCapabilitiesHash: string;
};
type FixerManifest = WorkerManifest & {
readonly fixerModelSlotId: string;
readonly fixerSystemHash: string;
readonly fixerMaxAttempts: number;
};WorkflowDefinitionHash = sha256(canonicalJson(WorkflowDefinitionSnapshot)) — stable at authoring time. Distinct from WorkflowVersion = sha256(canonicalJson(LWIR)) which varies per planner draft.
Outer Loop (Layer B)
When maxOuterCycles > 1 and planner.supervise is defined, runWorkflow drives multiple compile-and-run cycles.
type SuperviseOuterLoopState = {
readonly goal: {
readonly workflowDefinitionHash: string;
readonly description: string;
};
readonly cycles: ReadonlyArray<{
readonly cycleNumber: number;
readonly workflowVersionId: string;
readonly runId: string;
readonly status: "completed" | "failed";
readonly output: unknown;
readonly summary?: string;
}>;
};
type SuperviseDecision =
| { readonly kind: "done"; readonly finalOutput: unknown }
| { readonly kind: "continue"; readonly promptNote?: string };If maxOuterCycles > 1 and supervise is absent on planner, runWorkflow throws RunFailedError { causeCode: "runtime_config_error" }. When supervise returns { kind: "continue" } on the final cycle, the run fails with RunFailedError { causeCode: "outer_loop_exhausted" }.
Error Types
RunFailedError
class RunFailedError extends Error {
readonly runId: string;
readonly workflowVersionId: string;
readonly failedStepPath?: string;
readonly causeCode: RunFailedCauseCode;
readonly result?: FailedRunResult;
readonly outerLoopId?: string;
readonly lastCycleOutput?: unknown;
}
type RunFailedCauseCode =
| "input_schema_error"
| "planner_validation_exhausted"
| "capability_drift"
| "runtime_config_error"
| "step_schema_error"
| "step_failed"
| "max_visits_exceeded"
| "timeout"
| "cancelled"
| "outer_loop_exhausted";outerLoopId and lastCycleOutput are populated only on outer_loop_exhausted failures. failedStepPath is populated on max_visits_exceeded.
isRunFailedError
function isRunFailedError(value: unknown): value is RunFailedError;Narrows a caught error to RunFailedError. Prefer it to instanceof RunFailedError or a check on err.name: it also recognizes the error thrown by another copy of the package, such as a workflow folder loaded with loadWorkflow.
import { isRunFailedError, runWorkflow } from "@little-stack/workflow";
try {
await runWorkflow(workflow, input);
} catch (err) {
if (!isRunFailedError(err)) throw err;
console.error(err.runId, err.causeCode, err.failedStepPath);
}Policy Types
type ToolSelectionPolicy = "planner_selected" | "all" | "explicit_only";Runtime Boundary
Runtime APIs are Promise-based. Effect may power internals once the runtime lands, but it does not leak into the public alpha SDK.