Little documentation
Guides and APIReference

streamHarness()

Stream an interactive Little Harness agent turn.

Little Harness: published on npm as little-harness@0.2.0-alpha.0 (2026-10-05). Sections labeled UNRELEASED describe source-only changes. Release status

streamHarness() runs a harness and streams the response. Use it for chat routes, live progress, and UI experiences that should update while the agent is working.

It should feel close to AI SDK streamText(), while also owning sessions, files, artifacts, Persistent Dirs, and trace metadata.

Import

import { streamHarness } from "@little-stack/harness";

Basic Usage

import type { UIMessage } from "ai";
import { streamHarness } from "@little-stack/harness";

export async function POST(req: Request) {
  const { messages, chatId, extraBody }: {
    messages: UIMessage[];
    chatId: string;
    extraBody: { userId: string; teamId: string };
  } = await req.json();

  const result = streamHarness({
    harness,
    messages,
    session: chatId,
    extraBody,
  });

  return result.toUIMessageStreamResponse();
}

API Signature

function streamHarness<TOutput = string, TInput = unknown, TExtraBody = unknown>(
  options: StreamHarnessOptions<TInput, TExtraBody, TOutput>,
): StreamHarnessResult<TOutput>;

Parameters

harness

Harness

The reusable harness returned by createHarness().

streamHarness({ harness, messages });

messages

UIMessage[]

Raw AI SDK UI messages. Use this for normal chat routes.

const { messages } = await req.json();

const result = streamHarness({
  harness,
  messages,
});

When messages are provided, do not also provide type and input.

prompt

string

One user message, as plain text. Use it when the caller has a sentence rather than a chat transcript.

const result = streamHarness({
  harness,
  session: "loan_APP-1001",
  prompt: "Pre-approve APP-1001.",
});

Pass prompt or messages, not both, and not with type or input.

type

string

Open string that names a custom Input Type.

const result = streamHarness({
  harness,
  type: "document.review",
  input: { documentId: "doc_123" },
  session: "doc_123",
});

If the type is registered in createHarness({ inputTypes }), Little Harness can validate the input, convert it into model messages, and apply default output settings. If it is not registered, the call can still run with a warning.

input

TInput

App-defined payload for type + input calls.

input: {
  documentId: "doc_123",
  reviewMode: "risk",
}

Use messages for chat, prompt for one plain-text request, and type + input for non-chat callers with a typed payload.

session

string | HarnessSession

Session key or session handle. Omit it for one-shot work. Pass a stable id when the next request should continue the same workspace.

streamHarness({
  harness,
  messages,
  session: chatId,
});

extraBody

TExtraBody

App-defined request context. Little Harness should pass it to tools, chat.stageMessage, Input Type hooks, Persistent Dir callbacks, event callbacks, and trace metadata.

extraBody: {
  userId: "user_123",
  teamId: "team_456",
  role: "account_manager",
}

Little Harness should not impose an auth or tenancy model on this object.

model

LanguageModel

Per-call model override. createHarness() still requires a default model in the TypeScript API; use this when request-time configuration temporarily overrides that default.

streamHarness({
  harness,
  messages,
  model: requestModel,
});

system

string | ModelMessage | ModelMessage[]

Per-call system override. When provided, it replaces the harness-level system prompt for this turn. Input Type instructions, skills, memory, and runtime hints are still added around the selected system prompt.

streamHarness({
  harness,
  messages,
  system: requestSystem,
});

runtime

HarnessRuntimeOptions

Per-call runtime policy override. It merges with the harness-level runtime policy.

streamHarness({
  harness,
  messages,
  runtime: {
    bash: false,
    network: false,
  },
});

temperature

number

Per-call AI SDK temperature setting. Little Harness includes it in durable model request identity, so replay does not reuse a response generated with a different temperature.

output

Output

Optional AI SDK output specification for structured final output.

const result = streamHarness({
  harness,
  type: "support.ticket_triage",
  input: ticket,
  output: Output.object({ schema: triageSchema }),
});

const triage = await result.output;

If the Input Type defines an output shape, a per-call output should override it.

abortSignal

AbortSignal

Cancels staging, model calls, tool calls, and streaming when possible.

streamHarness({
  harness,
  messages,
  abortSignal: req.signal,
});

restage

boolean

Whether message staging should run again for messages that were already staged. Default should be false.

streamHarness({
  harness,
  messages,
  session: chatId,
  restage: true,
});

Use this only when intentionally rebuilding the session filesystem from message history.

runId

string

Durable identity of this run for kill-and-resume. Re-invoking with the same runId (and the same inputs) against the same session log replays the recorded run: recorded tool results are reused instead of re-executing tool side effects. Defaults to a fresh id per call.

streamHarness({
  harness,
  messages,
  session: chatId,
  runId: `chat:${chatId}:${messageId}`,
  sessionLog: remoteSessionLog({ baseUrl, authToken }),
});

sessionLog

HarnessSessionLog (type from @little-stack/harness/events)

Per-call session-log override (the durable event log this run appends to and replays from). When omitted, the log configured on createHarness() is used.

UNRELEASED: the current source removes the durability alias. Published 0.2.0-alpha.0 accepts durability as an alias of sessionLog (sessionLog wins when both are set). See Alpha Migrations.

See Execution Environments for the remote session-log adapter.

onEvent

Function that receives a HarnessEvent.

Optional per-call event callback.

streamHarness({
  harness,
  messages,
  onEvent: (event) => requestTrace.append(event),
});

onPersistenceError

Function that receives a PersistenceError.

Optional per-call persistence failure callback.

streamHarness({
  harness,
  messages,
  onPersistenceError: (error) => retryQueue.enqueue(error.failedCommits),
});

uiMessageStream

{ onError?: (error: unknown) => string }

Optional UI message stream formatting options. onError maps thrown stream errors to the text sent in AI SDK error chunks. Keep the default for public web responses when server-side error details should stay hidden; local tools such as the CLI can pass a more detailed formatter.

toolResultSpooling

false | { maxInlineBytes?: number; previewBytes?: number; outputDir?: string }

Per-call override for automatic oversized tool-result spooling. When omitted, the setting from createHarness() is used. If neither is configured, Little Harness spools tool results larger than 16 KB into /artifacts/tool-results. The compact result includes summary metadata with text counts and, for JSON content, a bounded structure sketch.

trace

false | HarnessTraceOptions

Per-call override for trace capture. When omitted, the setting from createHarness() is used.

streamHarness({
  harness,
  messages,
  session: chatId,
  trace: {
    content: {
      previewBytes: 256,
    },
  },
});

Set trace: false on a single call when the app should stream a response without persisting a Local Host trace for that request.

Agent Loop Options

streamHarness() forwards the common AI SDK loop controls:

streamHarness({
  harness,
  messages,
  stopWhen,
  prepareStep,
  toolChoice,
  activeTools,
  temperature,
  providerOptions,
  headers,
  maxRetries,
});

stopWhen takes AI SDK 7 stop conditions such as isStepCount(8) (imported from ai). When it is omitted, Little Harness uses isStepCount(workflowBudgets.maxModelSteps) — 20 by default — so streamed turns can continue after executed tool calls.

connector

HarnessActiveConnector

Identity of the active connector for this turn: { id, kind, endpoint? }. It is surfaced to tool execute handlers as ctx.connector. Connector loaders (loadChatSdkConnector / loadWebRichConnector) populate it automatically when a string connector reference or explicit connectorId is used; direct callers rarely set it themselves.

connectorTools

ToolSet

Connector-scoped tool overrides resolved from connectors/<id>/tools/* by the connector loaders. When present, Little Harness runs resolveConnectorTools(harnessTools, connectorTools) before MCP/workflow merging: executable connector tools override or add to the base tools, and abstract base tools (no execute) are exposed to the model only when the active connector supplies an executable extension. Like connector, this is loader plumbing and is not usually set by hand.

toolPolicy

{ allow?: string[]; deny?: string[] }

Optional connector tool access policy applied after connector tools resolve. allow exposes only the listed names; deny removes names and wins over allow; unknown names are ignored. The connector loaders forward descriptor.toolPolicy here. It scopes the agent's own tools, not bash or MCP/workflow tools.

Abstract-tool hiding

Abstract tools (declared without an execute) are structural placeholders and are never sent to the model — this now holds on plain streamHarness/generateHarness/CLI runs, not just connector-loaded ones. Each hidden tool adds one warning to result.finished.warnings:

{ code: "policy_warning", metadata: { reason: "abstract-tool-hidden", tool } }

bash and runtime tools are unaffected.

Returns

text

PromiseLike<string>

The complete generated text. It can be awaited alongside toUIMessageStreamResponse() because Little Harness tees the UI message stream. The UI message stream itself can still only be consumed once.

const text = await result.text;

output

Promise-like value for the parsed output.

The parsed structured output when output is provided.

const output = await result.output;

textStream

AsyncIterable<string>

Incremental assistant text, like AI SDK streamText's .textStream — for non-AI-SDK consumers that need to pipe the reply elsewhere (chat frameworks, CLIs, queues). It yields only assistant text deltas; reasoning and protocol chunks are skipped. This is the surface the Chat SDK connector loader posts to a platform thread (thread.post(result.textStream)). Like the UI message stream, it lazily consumes the underlying stream, so iterate it or toUIMessageStream*() — not both.

for await (const delta of result.textStream) {
  process.stdout.write(delta);
}

toUIMessageStream()

Function that returns a readable stream of UIMessageChunk values.

Converts the result to an AI SDK-compatible UI message stream.

toUIMessageStreamResponse()

() => Response

Creates a streamed Response for useChat().

return result.toUIMessageStreamResponse();

finished

Promise that resolves to StreamHarnessTurnFinished (in published 0.2.0-alpha.0, StreamHarnessFinished; see the table below).

Resolves after the model, tools, file writes, artifact creation, and persistence commit work has finished.

If the AI SDK UI message stream emits an error chunk, the turn is failed even when partial text already reached the client. uiMessageStream.onError only formats the client-facing error chunk; result.text, result.output, and result.finished still reject with the underlying error when available. After-turn Persistent Dir commits run only for successful turns, so apps should handle finished rejection when they need retry or cleanup behavior.

const finished = await result.finished;

if (finished.status === "completed" && finished.persistence.status === "failed") {
  await retryQueue.enqueue(finished.persistence.failedCommits);
}

When a tool parks the turn until pending work settles, finished resolves to status: "parked" with continuationId and pending, plus artifacts, trace and warnings, and without persistence or commitManual. resumeParked() continues a turn that parked in await_tasks, as a generateHarness() turn; see Asking A Person. The guard above compiles with every version. What the type says differs:

VersionDeclared type of finished
Published 0.2.0-alpha.0StreamHarnessFinished, an alias of StreamHarnessCompletedResult only. The runtime can still resolve to a parked result; the type does not include it.
UNRELEASED source (no npm version yet)StreamHarnessTurnFinished, the union of the completed and parked results. StreamHarnessFinished no longer exists.

See Typed parked results. A completed turn has status: "completed" and these fields:

FieldTypeDescription
sessionHarnessSessionThe session that ran the turn.
artifactsArtifactRef[]Artifacts created during the run.
traceTraceRefHost-facing trace reference.
persistencePersistenceStatusPersistent Dir commit status.
commitManual() => Promise<PersistenceStatus>Commits commit: "manual" Persistent Dirs for this completed turn.
warningsHarnessWarning[]Undefined input types, provider warnings, staging warnings, or policy warnings.

Examples

Chat Route

export async function POST(req: Request) {
  const body = await req.json();

  const result = streamHarness({
    harness,
    messages: body.messages,
    session: body.chatId,
    extraBody: {
      userId: body.userId,
      teamId: body.teamId,
    },
  });

  return result.toUIMessageStreamResponse();
}

Stream A Custom Input

const result = streamHarness({
  harness,
  type: "document.review",
  input: {
    documentId: "doc_123",
    requestedChecks: ["renewal-risk", "privacy"],
  },
  session: "doc_123",
});

return result.toUIMessageStreamResponse();

Observe Persistence After Streaming

const result = streamHarness({
  harness,
  messages,
  session: chatId,
});

result.finished.then(async ({ persistence }) => {
  if (persistence.status === "failed") {
    await retryQueue.enqueue(persistence.failedCommits);
  }
});

return result.toUIMessageStreamResponse();

On this page