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:
- System prompt assembly (dev system + role addendum + skill list + memory/scratch descriptions + tools).
- Per-turn event recording (
harness.model.calledbefore,harness.model.respondedafter each model invocation). - Durability fast-forward — checks for prior committed events before invoking the model or a tool.
- Memory + scratch directory creation and bash-allowlist population.
- Skill body staging at
.agents/skills/<name>/from the role/root working directory. ctx.abortSignalhonor 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.toolsctx.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 kind | Sequence |
|---|---|
plan | harness.session.started → (harness.model.called → harness.model.responded → harness.tool_call.started/succeeded × tools) × turns → harness.session.completed |
orchestrate | same 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_step | harness.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:
- Read events for
scope.runId. - For each
harness.model.calledwith a matchingharness.model.responded, replay the response into conversation state — skip the model call. - For each
harness.tool_call.startedwith a matchingharness.tool_call.succeeded, replay the result. - 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.abortSignalis 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.failedis emitted witherror.causeCode: "cancelled"; the harness throwsHarnessCancelled; the SDK writesharness.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.
Related Pages
- Memory + Scratch — how memory and scratch mounts are constructed and scoped
- Fixer — the
fix_steptask in detail - Identity Manifests — what goes into
harness.session.started.manifest - Event Log — the underlying append-only SQLite/WAL event store