Little documentation
Guides and APIReference

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:

ImportExports
@little-stack/workflowdefineWorkflow, 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/lwirvalidateLwir, assertValidLwir, LwirValidationError, registerWorkflowVersion, and the LWIR types
@little-stack/workflow/evalEval-set validation, hashing and storage
@little-stack/workflow/scaffoldThe generators behind little setup and little init
@little-stack/workflow/internalsUnstable, 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 cast

The 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

FieldRequiredTypeNotes
idyesstringStable identifier; hashed into WorkflowDefinitionHash
descriptionrecommendedstringShown to the planner, and used as the tool description by asHarnessWorkflow
labelnostringHuman-readable run label; defaults to the workflow id. RunWorkflowOptions.label overrides it per run
inputrecommendedZod, Standard Schema, or JSON SchemaValidated at run start; must fit the LWIR schema subset
suggestedInputnoZod or JSON SchemaA schema hint shown to the planner; not used to validate input
outputrecommendedschema or OutputModePlain schema (array/object/text inferred) or an explicit output.* mode; see Output Modes
modelone of model, models, planAiSdkModel or ModelSlotOne execution model
modelsone of model, models, planModelSlot[] (≥ 1)Execution model options the planner can reference in LWIR step.with.model. Not with model
policynostringRules 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
plannernoAiSdkModel, ModelSlot, or PlannerConfigA bare model is lifted into a PlannerConfig; defaults to the first model
planno{ steps } or LwirWorkflowA fixed plan that replaces the planner; no model needed unless a step calls one. Not with planner
toolsnoRecord<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
toolSelectionno"planner_selected" | "all" | "explicit_only"Recorded in the capability manifest; default "planner_selected". "explicit_only" keeps the plan from referencing the declared tools
workflowVersionReuseStrategyno"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
workernoWorkerConfigDefaults to the SDK workflowHarness
memorynoMemoryConfigDefaults to { workflow: "rw", org: "ro" }
bashnoBashCapabilitiesRole-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 from modelId + 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:

pathOrUrl:string

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.

options:RemoteSkillOptions

Remote Git options used when pathOrUrl is a remote source.

RemoteSkillOptions
skills?:readonly string[]

Selected remote skill names, matching repeated --skill usage in the Skills CLI. If omitted, every non-internal skill in the repository is installed.

skillMaxRisk?:SkillRiskLevel

Maximum accepted risk for all selected skills from this source.

skillRisk?:Record<string, SkillRiskLevel>

Per-skill maximum risk overrides for selected remote skills.

auth?:SkillGitAuth

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 names

Remote 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 (AgentWorkflowRegistry from @little-stack/harness). defined(record, { sessionId, turnId }) runs after a revision is stored and may return a note for the agent and metadata for the run runWith starts; 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 — a World, or a function of a DynamicWorkflowWorldContext (kind, workflowId, handle, runId, dataDir, sessionId, turnId, toolCallId, and for a defined workflow its definition, revision and registry metadata) that returns one, for agent-defined workflow and one-shot plan runs. Default: localWorld in 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 }). runWorkflow is 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. createOrchestratorTools and workflowSnapshotsForOrchestrator are 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 registry

Rates 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 kind

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

On this page