Little documentation
Guides and APIInternals

Harness

The agent-runtime primitive — contract, default workflowHarness, event vocabulary, durability, and plugin model.

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

The Harness is the agent runtime every role lives in: model + bash + scoped storage + tools + persistent conversation + event log, in the spirit of Claude Code. One interface serves four task kinds. The SDK ships a default workflowHarness; custom implementations plug in by conforming to the same contract.

The Harness contract and the default workflowHarness implementation (plus HarnessTask / HarnessContext / HarnessRole) live in packages/little-harness/src/workflow-harness/; packages/little-workflow/src/harness/ re-exports them.

Contract

export type Harness = {
  run(task: HarnessTask, ctx: HarnessContext): Promise<HarnessResult>;
};

export type HarnessTask =
  | PlanTask
  | OrchestrateTask
  | ExecuteStepTask
  | FixStepTask;

export type PlanTask = {
  readonly kind: "plan";
  readonly workflowSnapshot: WorkflowDefinitionSnapshot;
  readonly input: unknown;
  readonly outerLoopContext?: OuterLoopContext;
};

export type OrchestrateTask = {
  readonly kind: "orchestrate";
  readonly available: readonly WorkflowDefinitionSnapshot[];
  readonly input: unknown;
};

export type ExecuteStepTask = {
  readonly kind: "execute_step";
  readonly step: LwirStep;
  readonly stepInput: unknown;
  readonly stepContext: { stepPath: string; visitIndex: number };
};

export type FixStepTask = {
  readonly kind: "fix_step";
  readonly step: LwirStep;
  readonly stepInput: unknown;
  readonly originalAttemptError: ErrorEnvelope;
  readonly priorFixAttempts: number;
};

export 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" };

ctx.tools arrives pre-merged from the SDK — role-specific tools (plan_workflow, start_workflow, etc.) are already present; the harness exposes them to the model as-is. ctx.durability is the event writer; the harness calls it for every model turn and tool invocation. Little Workflow also supplies optional ctx.bash and ctx.recorder compatibility conveniences for custom harnesses that still use those helpers directly.

HarnessContext

export type HarnessContext = {
  readonly scope: HarnessScope;
  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 bash?: BashTool;
  readonly recorder?: HarnessEventRecorder;
};

export type HarnessScope = {
  readonly runId: RunId;
  readonly logDir: string;
  readonly role: HarnessRole;
  readonly parentRunId?: RunId;
};

export type HarnessRole =
  | "planner"
  | "orchestrator"
  | "worker.ai-generate"
  | "worker.code-run"
  | "worker.tool-call"
  | "fixer";

scope.logDir is the absolute path for events and scratch. scope.parentRunId links a sub-run to its orchestrator.

Bash Tool

ctx.bash is the role-scoped command interface exposed inside harness sessions. It is SDK-injected and event-logged as a harness tool call; it is not part of the developer Tool Registry.

Alpha implements the Bash tool with just-bash. The runtime creates explicit mounts for Memory Stores, Run Scratch, and Skills, then gives the session a persistent sandboxed shell state. The default cwd is /mnt/scratch/own/ when the role has writable scratch.

type BashTool = {
  execute(input: {
    cmd: string;
    cwd?: string;
    timeoutMs?: number;
  }): Promise<{
    stdout: string;
    stderr: string;
    exitCode: number;
  }>;
};

The sandbox defaults are:

  • Network: off.
  • Python: on, inside the sandbox.
  • JavaScript: on, inside the sandbox.
  • Filesystem: explicit mounts only; read/write mode follows the Memory Store, Run Scratch, and Skill mount descriptors.

Developers can enable network at the workflow, run, or orchestrator level:

defineWorkflow({
  // ...
  bash: {
    network: {
      allow: ["https://api.example.com/"],
      methods: ["GET", "HEAD"],
      denyPrivateRanges: true,
    },
  },
});

Effective precedence is closest scope first: workflow.bash for planner/worker/fixer sessions, orchestrator.bash for orchestrator sessions, then the bash run option (runWorkflow(workflow, input, { bash })), then defaults. The normalized Bash capabilities are hashed into the OrchestrationRequest.capabilityManifest and every harness session manifest.

Replay never re-executes Bash commands. It replays the recorded stdout, stderr, and exitCode from harness tool-call events.

Default workflowHarness

packages/little-harness/src/workflow-harness/workflow-harness.ts is the SDK-shipped implementation:

export const workflowHarness: Harness = {
  async run(task, ctx) {
    switch (task.kind) {
      case "plan":
        return runPlanTask(task, ctx);

      case "orchestrate":
        return runOrchestrateTask(task, ctx);

      case "execute_step":
        return runExecuteStepTask(task, ctx);

      case "fix_step":
        return runFixStepTask(task, ctx);
    }
  },
};

The agent-loop path calls AI SDK streamText when available, otherwise generateText, and adds:

  1. System prompt assembly (dev system + role addendum + skill list + memory/scratch descriptions + tools).
  2. Per-turn event recording (harness.model.called before, harness.model.responded after each model invocation).
  3. Durability fast-forward — checks for prior committed events before invoking the model or a tool.
  4. Memory + scratch directory creation and bash-allowlist population.
  5. Skill body staging at .agents/skills/<name>/ from the role/root working directory.
  6. ctx.abortSignal honor throughout.

executeStep dispatches non-agent step types directly:

async function executeStep(task: ExecuteStepTask, ctx: HarnessContext): Promise<HarnessResult> {
  switch (task.step.uses) {
    case "ai.generate":
      return runAgentLoopForAiStep(task, ctx);

    case "code.run":
      return executeCodeRunStep(task, ctx);

    case "tool.call":
      return executeToolCallStep(task, ctx);

    case "decision":
      return executeDecisionStep(task, ctx);

    case "parallel":
      throw new Error("parallel orchestration is the runtime's job, not the harness's");
  }
}

Direct-execute steps (code.run, tool.call, decision) emit harness.session.started, harness.execute_step.started, harness.execute_step.succeeded, and harness.tool_call.started, harness.tool_call.succeeded events but no harness.model.called events — there is no model in the loop.

System Prompt Assembly

The harness assembles the system prompt in this order:

1. ctx.system                           dev-supplied intent
2. Role addendum                        SDK-shipped per role
3. Skill frontmatter list               name + description of each skill (bodies via bash)
4. Memory + scratch mount descriptions  paths, modes, and purpose
5. Available tools                      description + input schema from ctx.tools

ctx.system is first so developer intent shapes the model's attention; SDK additions are explicit append-only. Example for an orchestrator session:

[ctx.system]
You orchestrate candidate-evaluation workflows. Plan once per shared context; spawn parallel runs.

[Role addendum]
You are the Orchestrator for a Little Workflow run. Your tools:
- plan_workflow(workflowId, input) -> workflowVersionId
- run_workflow(workflowVersionId, input) -> output
- start_workflow(workflowId, input) -> output
- bash(cmd) -> {stdout, stderr, exitCode}

[Skill frontmatter list]
Available skills (read via `cat .agents/skills/<name>/SKILL.md`):
- batch-fan-out: Patterns for parallel tool calling when processing many items.

[Memory + scratch mount descriptions]
Memory mounts (read with bash):
- /mnt/memory/pipeline/ (rw) — persistent notes for this pipeline.
- /mnt/memory/org/ (ro) — organization-wide knowledge.

Scratch mounts (ephemeral, this run only):
- /mnt/scratch/own/ (rw) — your working area.

[Available tools]
fetchCandidates: Fetch candidates from ATS by job id. Input: {jobId: string}.

Event Vocabulary

All roles emit from the same event types. The SET emitted differs by task kind.

Session events (all roles)

type SessionStartedEvent = {
  type: "harness.session.started";
  payload: {
    runId: RunId;
    role: HarnessRole;
    task: { kind: HarnessTask["kind"] };
    manifest: OrchestratorManifest | PlannerManifest | WorkerManifest | FixerManifest;
    manifestHash: string;
    parentRunId?: RunId;
  };
};

type SessionCompletedEvent = {
  type: "harness.session.completed";
  payload: {
    runId: RunId;
    output?: unknown;
    // Token-only rollup. The harness cannot see the model registry — it lives in
    // little-workflow, which depends on this package — so cost is derived downstream
    // instead, and `costUsd` on a recorded usage payload is rejected by the recorder.
    usage: {
      inputTokens: number;
      outputTokens: number;
      cachedInputTokens: number;
      reasoningTokens: number;
    };
  };
};

type SessionFailedEvent = {
  type: "harness.session.failed";
  payload: { runId: RunId; error: ErrorEnvelope };
};

Agent-loop events (plan / orchestrate / ai.generate / fix_step)

type ModelCalledEvent = {
  type: "harness.model.called";
  payload: {
    turn: number;
    promptHash: string;
    request: {
      model: string;
      messages: Message[];
      tools: ToolReference[];
    };
  };
};

type ModelRespondedEvent = {
  type: "harness.model.responded";
  payload: {
    turn: number;
    callId: string;
    stepPath?: string;                             // stamped by the runtime bridge
    model: { provider: string; modelId: string };  // pricing key; must ride the payload
    response: {
      text?: string;
      toolCalls?: ToolCallRequest[];
      usage: {
        inputTokens: number;
        outputTokens: number;
        cachedInputTokens?: number;                // subset of inputTokens
        reasoningTokens?: number;                  // subset of outputTokens
      };
    };
  };
};

model is on the payload rather than the trace metadata because only the payload is persisted to the durable event log, and the run materializer needs the provider/model pair to look up registry rates. stepPath is injected by the runtime→harness bridge (the harness itself has no notion of workflow steps); it is what makes per-step cost attribution correct when parallel branches interleave their events in one log.

Tool-call events (all roles)

type ToolCallStartedEvent = {
  type: "harness.tool_call.started";
  payload: {
    turn?: number;
    callId: string;
    caller: "model" | "code";
    toolName: string;
    args: unknown;
  };
};

type ToolCallSucceededEvent = {
  type: "harness.tool_call.succeeded";
  payload: {
    callId: string;
    caller: "model" | "code";
    toolName: string;
    args: unknown;
    callIndex?: number;
    result: unknown;
    scope?: Record<string, unknown>;
    durationMs?: number;
  };
};

type ToolCallFailedEvent = {
  type: "harness.tool_call.failed";
  payload: {
    callId: string;
    caller: "model" | "code";
    toolName: string;
    args: unknown;
    callIndex?: number;
    error: ErrorEnvelope;
    scope?: Record<string, unknown>;
    durationMs?: number;
  };
};

caller: "model" means the provider returned a tool call from the agent loop. caller: "code" means a code.run step invoked the tool directly without the model in the loop.

Direct-execute step events (code.run / tool.call / decision)

type ExecuteStepStartedEvent = {
  type: "harness.execute_step.started";
  payload: {
    runId: RunId;
    stepId: string;
    uses: "ai.generate" | "code.run" | "tool.call" | "decision" | "parallel";
    stepPath: string;
    visitIndex: number;
  };
};

type ExecuteStepSucceededEvent = {
  type: "harness.execute_step.succeeded";
  payload: {
    runId: RunId;
    stepId: string;
    uses: "ai.generate" | "code.run" | "tool.call" | "decision" | "parallel";
    stepPath: string;
    visitIndex: number;
    output: unknown;
  };
};

Event sequence by task kind

Task kindSequence
planharness.session.started → (harness.model.called → harness.model.responded → harness.tool_call.started/succeeded × tools) × turns → harness.session.completed
orchestratesame as plan
execute_step (ai.generate)harness.session.started → harness.execute_step.started → harness.model.called → harness.model.responded → harness.execute_step.succeeded → harness.session.completed (one model call, no tool calls)
execute_step (code.run)harness.session.started → harness.execute_step.started → (harness.tool_call.started/succeeded × tools) → harness.execute_step.succeeded → harness.session.completed
execute_step (tool.call)harness.session.started → harness.execute_step.started → harness.tool_call.started → harness.tool_call.succeeded → harness.execute_step.succeeded → harness.session.completed
execute_step (decision)harness.session.started → harness.execute_step.started → harness.execute_step.succeeded → harness.session.completed
fix_stepharness.session.started → agent-loop events × turns → harness.session.completed (with fixedSource in result)

Durability

Every model call and tool call is event-logged. On resume, the harness fast-forwards by replaying committed events:

  1. Read events for scope.runId.
  2. For each harness.model.called with a matching harness.model.responded, replay the response into conversation state — skip the model call.
  3. For each harness.tool_call.started with a matching harness.tool_call.succeeded, replay the result.
  4. When the log catches up to the crash boundary, resume from that point.

This mirrors LWIR per-step memoization one level up. Because both model calls AND tool calls are memoized, resumed sessions reach the same state byte-for-byte as the original.

Sub-runs (spawned by start_workflow / run_workflow) maintain their own event logs and their own per-step memoization. The orchestrator's harness.tool_call.started reserves args.subRunId and stays in-flight until the sub-run completes; two levels of memoization compose cleanly. See Identity Manifests for crash recovery scenarios.

Cancellation

  • ctx.abortSignal is honored throughout the agent loop and in long-running tool calls.
  • On abort: any in-flight model call is allowed to complete; any in-flight tool call is signaled and harness.tool_call.failed is emitted with error.causeCode: "cancelled"; the harness throws HarnessCancelled; the SDK writes harness.session.failed.

Plugin Model — Custom Harnesses

Any object conforming to Harness is a valid harness:

import type { Harness } from "@little-stack/workflow/internals";

export const cloudflareHarness: Harness = {
  async run(task, ctx) {
    if (task.kind === "execute_step") {
      // Delegate direct-execute steps to the default.
      return { kind: "delegate_to_default" };
    }

    const response = await fetch("https://my-harness.workers.dev/run", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        task,
        scope: ctx.scope,
        modelSlotId: ctx.model.slotId,
        system: ctx.system,
        toolsManifest: Object.fromEntries(
          Object.entries(ctx.tools).map(([k, t]) => [k, { description: t.description, inputSchema: t.inputSchema }])
        ),
      }),
    });
    return await response.json();
  },
};

Constraints:

  • Must emit the SDK's event vocabulary. Without this, replay and audit break.
  • Must honor per-step memoization on resume (check ctx.recorder.priorEvents(runId) before invoking model or tool).
  • Must honor ctx.abortSignal.

Delegation Sentinel

When a custom harness wants the SDK default for a specific task kind:

async run(task, ctx) {
  if (task.kind === "execute_step" && task.step.uses === "code.run") {
    return { kind: "delegate_to_default" };
  }
  // ...
}

The SDK sees delegate_to_default, invokes workflowHarness.run(task, ctx), and returns its result. This is preferred over calling workflowHarness.run() directly inside the custom harness — it lets the SDK manage event recording, abort signal forwarding, and manifest consistency.

  • Memory + Scratch — how memory and scratch mounts are constructed and scoped
  • Fixer — the fix_step task in detail
  • Identity Manifests — what goes into harness.session.started.manifest
  • Event Log — the underlying append-only SQLite/WAL event store

On this page