Claude Code, Codex, OpenCode et al. are amazing general purpose agent harnesses that go far beyond just software development.
And while Anthropic offers the Claude Agent SDK, OpenAI now offers the Codex App Server, and OpenCode has a client to connect to an OpenCode instance, these harnesses are very "heavy" to use programmatically.
OpenHarness is an open source project based on Vercel's AI SDK that aims to provide the building blocks to build very capable, general-purpose agents in code. It is inspired by all of the aforementioned coding agents.
OpenHarness is a pnpm monorepo with three packages and three example apps:
| Package | Description |
|---|---|
@openharness/core |
Agent, Session, Conversation, middleware, tools, UI stream integration |
@openharness/react |
React hooks and provider for AI SDK 5 chat UIs |
@openharness/vue |
Vue 3 composables and provider for AI SDK 5 chat UIs |
examples/cli |
Interactive terminal agent with tool approval and subagent display |
examples/nextjs-demo |
Next.js chat app using @openharness/react |
examples/nuxt-demo |
Nuxt 4 chat app using @openharness/vue |
pnpm install
pnpm buildBoth examples require an OPENAI_API_KEY. Create a .env file in the repo root:
echo "OPENAI_API_KEY=sk-..." > .envAn interactive terminal agent with tool approval prompts, subagent display, and compaction.
pnpm --filter cli-demo startA chat app with streaming, tool visualization, subagent status, and an announce tool for agent narration.
cp examples/nextjs-demo/.env.example examples/nextjs-demo/.env
# Edit .env and add your OPENAI_API_KEY
pnpm --filter nextjs-demo devThen open http://localhost:3000.
The same chat experience built with Vue 3 and Nuxt 4.
pnpm --filter nuxt-demo devThen open http://localhost:3000.
The Agent class is the core primitive. An agent wraps a language model, a set of tools, and a multi-step execution loop into a stateless executor that you can run() with a message history and new input.
import { Agent, fsTools, bash } from "@openharness/core";
import { openai } from "@ai-sdk/openai";
const agent = new Agent({
name: "dev",
model: openai("gpt-5.2"),
systemPrompt: "You are a helpful coding assistant.",
tools: { ...fsTools, bash },
maxSteps: 20,
});agent.run() is an async generator that takes a message history and new input, and yields a stream of typed events as the agent works. The agent is stateless — it doesn't accumulate messages internally. You pass the conversation history in and get the updated history back in the done event.
import type { ModelMessage } from "ai";
let messages: ModelMessage[] = [];
for await (const event of agent.run(messages, "Refactor the auth module to use JWTs")) {
switch (event.type) {
case "text.delta":
process.stdout.write(event.text);
break;
case "tool.start":
console.log(`Calling ${event.toolName}...`);
break;
case "tool.done":
console.log(`${event.toolName} finished`);
break;
case "done":
messages = event.messages; // capture updated history for next turn
console.log(`Result: ${event.result}, tokens: ${event.totalUsage.totalTokens}`);
break;
}
}This makes it easy to build multi-turn interactions — just pass the messages from the previous done event into the next run() call. It also means you have full control over the conversation history: you can inspect it, modify it, or share it between agents.
The full set of events emitted by run():
| Event | Description |
|---|---|
text.delta |
Streamed text chunk from the model |
text.done |
Full text for the current step is complete |
reasoning.delta |
Streamed reasoning/thinking chunk (if the model supports it) |
reasoning.done |
Full reasoning text for the step is complete |
tool.start |
A tool call has been initiated |
tool.done |
A tool call completed successfully |
tool.error |
A tool call failed |
step.start |
A new agentic step is starting |
step.done |
A step completed (includes token usage and finish reason) |
error |
An error occurred during execution |
done |
The agent has finished. result is one of "complete", "stopped", "max_steps", or "error" |
| Option | Default | Description |
|---|---|---|
name |
(required) | Agent name, used in logging and subagent selection |
model |
(required) | Any Vercel AI SDK LanguageModel |
systemPrompt |
— | System prompt prepended to every request |
tools |
— | AI SDK ToolSet — the tools the agent can call |
maxSteps |
100 |
Maximum agentic steps before stopping |
temperature |
— | Sampling temperature |
maxTokens |
— | Max output tokens per step |
instructions |
true |
Whether to load AGENTS.md / CLAUDE.md from the project directory |
approve |
— | Callback for tool call approval (see Permissions) |
subagents |
— | Child agents available via the task tool (see Subagents) |
maxSubagentDepth |
1 |
Maximum nesting depth for subagents. 1 = direct subagents only, 2 = sub-subagents, etc. |
subagentBackground |
— | Enable background subagent execution with lifecycle tools (see Background subagents) |
mcpServers |
— | MCP servers to connect to (see MCP Servers) |
skills |
— | Skills configuration (see Skills) |
While Agent is a stateless executor, Session adds the statefulness and resilience you need for interactive, multi-turn conversations. It owns the message history and handles compaction, retry, persistence, and lifecycle hooks automatically.
import { Session } from "@openharness/core";
const session = new Session({
agent,
contextWindow: 200_000,
});
for await (const event of session.send("Refactor the auth module")) {
switch (event.type) {
case "text.delta":
process.stdout.write(event.text);
break;
case "compaction.done":
console.log(`Compacted: ${event.tokensBefore} → ${event.tokensAfter} tokens`);
break;
case "retry":
console.log(`Retrying in ${event.delayMs}ms...`);
break;
case "turn.done":
console.log(`Turn ${event.turnNumber} complete`);
break;
}
}session.send() yields all the same AgentEvent types as agent.run(), plus additional session lifecycle events.
| Option | Default | Description |
|---|---|---|
agent |
(required) | The Agent to use for execution |
contextWindow |
— | Model context window size in tokens. Required for auto-compaction |
reservedTokens |
min(20_000, agent.maxTokens ?? 20_000) |
Tokens reserved for output |
autoCompact |
true when contextWindow is set |
Enable auto-compaction |
shouldCompact |
— | Custom overflow detection function |
compactionStrategy |
DefaultCompactionStrategy() |
Custom compaction strategy |
retry |
— | Retry config for transient API errors |
hooks |
— | Lifecycle hooks (see Hooks) |
sessionStore |
— | Pluggable persistence backend |
sessionId |
auto-generated UUID | Session identifier |
In addition to all AgentEvent types, session.send() yields:
| Event | Description |
|---|---|
turn.start |
A new turn is starting |
turn.done |
Turn completed (includes token usage) |
compaction.start |
Compaction triggered (includes reason and token count) |
compaction.pruned |
Tool results pruned (phase 1) |
compaction.summary |
Conversation summarized (phase 2) |
compaction.done |
Compaction finished (includes before/after token counts) |
retry |
Retrying after a transient error (includes attempt count and delay) |
When a conversation approaches the context window limit, the session automatically compacts the message history. The default strategy works in two phases:
- Pruning — replaces tool result content in older messages with
"[pruned]", preserving the most recent ~40K tokens of context. No LLM call needed. - Summarization — when pruning isn't enough, calls the model to generate a structured summary and replaces the entire history with it.
You can customize compaction at multiple levels:
import { DefaultCompactionStrategy } from "@openharness/core";
// Tune the default strategy
const session = new Session({
agent,
contextWindow: 128_000,
compactionStrategy: new DefaultCompactionStrategy({
protectedTokens: 60_000, // protect more recent context
summaryModel: cheapModel, // use a cheaper model for summarization
}),
});
// Or replace the strategy entirely
const session = new Session({
agent,
contextWindow: 128_000,
compactionStrategy: {
async compact(context) {
// your own compaction logic
return { messages: [...], messagesRemoved: 0, tokensPruned: 0 };
},
},
});
// Or go fully manual
const session = new Session({ agent, autoCompact: false });
// ...later:
for await (const event of session.compact()) { ... }Transient API errors (429, 500, 502, 503, 504, 529, rate limits, timeouts) are retried automatically with exponential backoff and jitter. Retries only happen before any content has been streamed to the consumer — once the model starts producing output, the session commits to that attempt.
const session = new Session({
agent,
retry: {
maxRetries: 5,
initialDelayMs: 2000,
maxDelayMs: 60_000,
isRetryable: (error) => error.message.includes("overloaded"),
},
});Hooks let you intercept and customize the session lifecycle:
const session = new Session({
agent,
hooks: {
// Modify messages before each LLM call
onBeforeSend: (messages) => {
return messages.filter(m => !isStale(m));
},
// Post-processing after each turn
onAfterResponse: ({ turnNumber, messages, usage }) => {
console.log(`Turn ${turnNumber}: ${usage.totalTokens} tokens`);
},
// Custom compaction prompt
onCompaction: (context) => {
return "Summarize with emphasis on code changes and file paths.";
},
// Custom error handling (return true to suppress)
onError: (error, attempt) => {
logger.warn(`Attempt ${attempt} failed: ${error.message}`);
},
},
});Plug in any storage backend by implementing the SessionStore interface:
const session = new Session({
agent,
sessionId: "user-123-conversation-1",
sessionStore: {
async load(id) { return db.get(id); },
async save(id, messages) { await db.set(id, messages); },
async delete(id) { await db.del(id); },
},
});
// Restore a previous session
await session.load();
// Messages are auto-saved after each turn, or save manually:
await session.save();The session's message history is directly readable and writable:
// Read current state
console.log(session.messages.length, session.turns, session.totalUsage);
// Inject or modify messages
session.messages.push({ role: "user", content: "Remember: always use TypeScript." });Session bundles compaction, retry, persistence, turn tracking, and hooks into a single class. If you want more control — composing only the behaviors you need, or writing custom middleware — use the functional Runner/Middleware/Conversation API instead.
A Runner is just an async generator function with the same shape as agent.run(). A Middleware transforms one Runner into another. You compose them with apply():
import {
Agent, Conversation, toRunner, apply,
withTurnTracking, withCompaction, withRetry, withPersistence, withHooks,
} from "@openharness/core";
const agent = new Agent({
name: "dev",
model: openai("gpt-5.2"),
tools: { ...fsTools, bash },
});
// Compose only the middleware you need
const runner = apply(
toRunner(agent),
withTurnTracking(),
withCompaction({ contextWindow: 200_000, model: agent.model }),
withRetry({ maxRetries: 5 }),
withPersistence({ store: myStore, sessionId: "abc" }),
);
const chat = new Conversation({ runner });
for await (const event of chat.send("Fix the bug in auth.ts")) {
if (event.type === "text.delta") process.stdout.write(event.text);
}
// chat.messages is automatically updated from the done eventMiddleware listed first in apply() wraps outermost. The ordering above means: turn tracking brackets everything, compaction runs once before retries, retry wraps only the agent call, and persistence saves after a successful response.
| Middleware | Description |
|---|---|
withTurnTracking() |
Emits turn.start/turn.done events. Maintains a turn counter across calls. |
withCompaction(config) |
Auto-compacts history when approaching the context window limit. Tracks lastInputTokens from step.done events. |
withRetry(config?) |
Retries on transient API errors (429, 500, etc.) with exponential backoff. Only retries before content has been streamed. |
withPersistence(config) |
Auto-saves messages to a SessionStore on every done event. |
withHooks(hooks) |
Applies SessionHooks (onBeforeSend, onAfterResponse, onError) around the inner runner. |
Conversation is a thin stateful wrapper over a composed Runner. It manages messages (updating from done events) and provides the same toUIMessageStream() and toResponse() methods as Session for AI SDK 5 integration:
const chat = new Conversation({ runner, sessionId: "abc", store: myStore });
// Optionally load previous messages
await chat.load();
// Send messages — chat.messages is updated automatically
for await (const event of chat.send("hello")) { ... }
// Manual save (separate from withPersistence auto-save)
await chat.save();
// Next.js route handler
return chat.toResponse(input);For lightweight event stream transforms, four curried combinators are available:
import { tap, filter, map, takeUntil } from "@openharness/core";
// Log every event
const logged = tap(e => console.log(e.type));
for await (const event of logged(agent.run([], "hello"))) { ... }
// Drop reasoning events (done events are never filtered)
const noReasoning = filter(e => e.type !== "reasoning.delta");
// Transform text events
const uppercased = map(e =>
e.type === "text.delta" ? { ...e, text: e.text.toUpperCase() } : e
);
// Stop after first text completion
const firstText = takeUntil(e => e.type === "text.done");A middleware is a function that takes a Runner and returns a Runner. Here's a simple logging middleware:
import type { Middleware } from "@openharness/core";
const withLogging: Middleware = (runner) =>
async function* (history, input, options) {
console.log(`Sending: ${typeof input === "string" ? input : "[messages]"}`);
for await (const event of runner(history, input, options)) {
if (event.type === "done") console.log(`Done: ${event.result}`);
yield event;
}
};
const runner = apply(toRunner(agent), withLogging, withRetry());For reusable middleware stacks, use pipe() to create a combined middleware:
import { pipe } from "@openharness/core";
const production = pipe(
withTurnTracking(),
withCompaction({ contextWindow: 200_000, model }),
withRetry({ maxRetries: 5 }),
);
// Apply the same stack to multiple runners
const runner1 = production(toRunner(agent1));
const runner2 = production(toRunner(agent2));Tools use the Vercel AI SDK tool() helper with Zod schemas. OpenHarness ships a set of built-in tools that you can use as-is, compose, or replace entirely.
| Tool | Description |
|---|---|
readFile |
Read file contents (supports line offset/limit) |
writeFile |
Write content to a file (creates parent dirs) |
editFile |
Find-and-replace within a file |
listFiles |
List files/directories (optionally recursive) |
grep |
Regex search across files (skips node_modules, .git) |
deleteFile |
Delete a file or directory |
All are exported individually and also grouped as fsTools. Available from the main entry point (@openharness/core) or the sub-path (@openharness/core/tools/fs).
Runs arbitrary shell commands via bash -c. Configurable timeout (default 30s, max 5min) and automatic output truncation. Available from the main entry point (@openharness/core) or the sub-path (@openharness/core/tools/bash).
Any AI SDK-compatible tool works. Just define it with tool() from the ai package:
import { tool } from "ai";
import { z } from "zod";
const myTool = tool({
description: "Do something useful",
inputSchema: z.object({ query: z.string() }),
execute: async ({ query }) => {
return { result: `You asked: ${query}` };
},
});
const agent = new Agent({
name: "my-agent",
model: openai("gpt-5.2"),
tools: { myTool },
});By default, all tool calls are allowed. To gate tool execution — for example, prompting a user for confirmation — pass an approve callback:
const agent = new Agent({
name: "safe-agent",
model: openai("gpt-5.2"),
tools: { ...fsTools, bash },
approve: async ({ toolName, toolCallId, input }) => {
// Return true to allow, false to deny
const answer = await askUser(`Allow ${toolName}?`);
return answer === "yes";
},
});When a tool call is denied, a ToolDeniedError is thrown and surfaced to the model as a tool error, so it can adjust its approach.
The callback receives a ToolCallInfo object:
interface ToolCallInfo {
toolName: string;
toolCallId: string;
input: unknown;
}The callback can be async — you can prompt a user in a terminal, show a modal in a web UI, or call an external approval service.
Agents can delegate work to other agents. When you pass a subagents array, a task tool is automatically generated that lets the parent agent spawn child agents by name.
const explore = new Agent({
name: "explore",
description: "Read-only codebase exploration. Use for searching and reading files.",
model: openai("gpt-5.2"),
tools: { readFile, listFiles, grep },
maxSteps: 30,
});
const agent = new Agent({
name: "dev",
model: openai("gpt-5.2"),
tools: { ...fsTools, bash },
subagents: [explore],
});The parent model sees a task tool with a description listing the available subagents and their descriptions. It can call task with an agent name and a prompt, and the subagent runs to completion autonomously.
Key behaviors:
- Fresh instance per task — each
taskcall creates a new agent with no shared conversation state - No approval — subagents run autonomously without prompting for permission
- Configurable nesting — by default subagents cannot themselves have subagents (
maxSubagentDepth: 1). Set a higher depth to enable nested delegation (see Nested subagents) - Abort propagation — the parent's abort signal is forwarded to the child
- Concurrent execution — the model can call
taskmultiple times in one response to run subagents in parallel
By default, subagents cannot delegate further. Set maxSubagentDepth to allow nesting:
const search = new Agent({
name: "search",
description: "Focused file search",
model: openai("gpt-5.2"),
tools: { grep, listFiles },
});
const explore = new Agent({
name: "explore",
description: "Read-only codebase exploration",
model: openai("gpt-5.2"),
tools: { readFile, listFiles, grep },
subagents: [search], // explore can delegate to search
});
const agent = new Agent({
name: "dev",
model: openai("gpt-5.2"),
tools: { ...fsTools, bash },
subagents: [explore],
maxSubagentDepth: 2, // allow explore → search nesting
});The depth decrements at each level: the root agent has depth 2, its child explore gets depth 1 (can use search), and search gets depth 0 (no further delegation).
To observe what subagents are doing in real time, pass an onSubagentEvent callback:
const agent = new Agent({
name: "dev",
model: openai("gpt-5.2"),
tools: { ...fsTools, bash },
subagents: [explore],
onSubagentEvent: (path, event) => {
// path is the ancestry chain, e.g. ["explore"] or ["explore", "search"]
if (event.type === "tool.done") {
console.log(`[${path.join(" > ")}] ${event.toolName} completed`);
}
},
});The path parameter is a string[] representing the full ancestry from outermost to innermost agent. For a direct subagent it's ["explore"]; for a nested sub-subagent it's ["explore", "search"]. Events from nested subagents automatically bubble up through the chain.
The callback receives the same AgentEvent types as the parent's run() generator.
By default, all subagent calls are synchronous — the parent blocks until the child finishes. Enable subagentBackground to let the parent spawn agents in the background, do other work, and collect results later. This works like JavaScript's Promise combinators (Promise.all, Promise.any, Promise.race, Promise.allSettled).
const agent = new Agent({
name: "dev",
model: openai("gpt-5.2"),
tools: { ...fsTools, bash },
subagents: [explore, researcher, coder],
subagentBackground: true, // enable with defaults
});When enabled, three things happen:
- The
tasktool gains an optionalbackgroundparameter. Whentrue, the agent is spawned in the background and the tool returns immediately with an agent ID. - An
agent_awaittool is registered for waiting on background agents using different strategies. agent_statusandagent_canceltools are registered for checking on and cancelling background agents.
The model orchestrates everything naturally through tool calls:
// Model spawns two background agents and one foreground agent in one step:
task({ agent: "researcher", prompt: "Find deprecated APIs", background: true }) → "bg-1"
task({ agent: "researcher", prompt: "Check test coverage", background: true }) → "bg-2"
task({ agent: "coder", prompt: "Refactor config parser", background: false }) → (blocks)
// Next step — model has the coder result, now collects background results:
agent_await({ ids: ["bg-1", "bg-2"], mode: "all" }) → both results
The agent_await tool supports four modes, matching JavaScript's Promise combinators:
| Mode | Behavior |
|---|---|
all |
Wait for all agents to succeed. Fails fast if any agent fails. |
allSettled |
Wait for all agents to finish. Returns results and errors. |
any |
Wait for the first agent to succeed. Only fails if all agents fail. |
race |
Wait for the first agent to settle (succeed or fail). |
Pass true for sensible defaults, or an object for fine-grained control:
const agent = new Agent({
name: "dev",
model: openai("gpt-5.2"),
tools: { ...fsTools, bash },
subagents: [explore, researcher],
subagentBackground: {
maxConcurrent: 3, // max simultaneous background agents (default: Infinity)
timeout: 120_000, // auto-cancel after 2 minutes (default: none)
autoCancel: true, // cancel background agents on agent.close() (default: true)
tools: {
status: true, // register agent_status tool (default: true)
cancel: true, // register agent_cancel tool (default: true)
await: ["all", "race"], // which await modes to expose (default: all four)
},
},
});| Option | Default | Description |
|---|---|---|
maxConcurrent |
Infinity |
Maximum number of background agents running simultaneously |
timeout |
— | Auto-cancel background agents after this many milliseconds |
autoCancel |
true |
Cancel all running background agents when agent.close() is called |
tools.status |
true |
Register the agent_status tool |
tools.cancel |
true |
Register the agent_cancel tool |
tools.await |
all four modes | Which await modes to expose. true = all, false = disable, or an array of specific modes |
When subagentBackground is enabled, these tools are auto-registered alongside the task tool:
| Tool | Description |
|---|---|
agent_status |
Non-blocking status check. Returns the agent's current status (running, done, failed, cancelled) and result if available. |
agent_cancel |
Cancel a running background agent via its ID. |
agent_await |
Block until background agents complete, using one of the four await modes. |
Background agents respect the parent's abort signal — if the parent is aborted, all background children are cancelled. When autoCancel is true (the default), calling agent.close() cancels any still-running background agents.
OpenHarness supports the AGENTS.md spec. On first run, the agent walks up from the current directory to the filesystem root looking for AGENTS.md or CLAUDE.md. The first file found is loaded and prepended to the system prompt.
This is enabled by default. Set instructions: false to disable it.
Agents can connect to Model Context Protocol servers. Tools from MCP servers are merged into the agent's toolset alongside any static tools.
const agent = new Agent({
name: "dev",
model: openai("gpt-5.2"),
tools: { ...fsTools, bash },
mcpServers: {
github: {
type: "stdio",
command: "npx",
args: ["-y", "@modelcontextprotocol/server-github"],
env: { GITHUB_TOKEN: process.env.GITHUB_TOKEN },
},
weather: {
type: "http",
url: "https://weather-mcp.example.com/mcp",
headers: { Authorization: "Bearer ..." },
},
},
});
// MCP connections are established lazily on first run()
for await (const event of agent.run([], "What PRs are open?")) { ... }
// Clean up MCP connections when done
await agent.close();Three transport types are supported:
| Transport | Use case |
|---|---|
stdio |
Local servers — spawns a child process, communicates over stdin/stdout |
http |
Remote servers via Streamable HTTP (recommended for production) |
sse |
Remote servers via Server-Sent Events (legacy) |
When multiple MCP servers are configured, tools are namespaced as serverName_toolName to avoid collisions. With a single server, tool names are used as-is.
Skills are reusable instruction packages — markdown documents that get loaded into the LLM conversation on demand. They provide domain-specific knowledge, workflows, and reference material without executing arbitrary code. Think of them as context-injecting tools: the model calls a skill tool, and the skill's markdown content is returned as structured output.
Each skill is a directory containing a SKILL.md file with YAML frontmatter:
my-skill/
├── SKILL.md # Required — skill definition
├── references/ # Optional — auxiliary files the model can read
│ └── api.md
└── scripts/
└── setup.sh
---
name: my-skill
description: A short description of what this skill does
---
# My Skill
Full markdown content here. This is the prompt that gets injected
into the conversation when the skill is loaded.Skill names must be lowercase alphanumeric with single hyphens (^[a-z0-9]+(-[a-z0-9]+)*$).
Point the agent at one or more directories containing skill folders:
const agent = new Agent({
name: "dev",
model: openai("gpt-5.2"),
tools: { ...fsTools, bash },
skills: {
paths: ["./skills", "~/.my-app/skills"],
},
});Paths can be absolute, relative (resolved from cwd), or use ~/ for the home directory. When multiple paths contain a skill with the same name, later paths take precedence.
When skills is configured, a skill tool is automatically added to the agent's toolset (similar to how the task tool is auto-generated for subagents). The tool's description includes an XML listing of all available skills, so the model knows what it can invoke.
Skills are discovered lazily on the first run() call and cached for the agent's lifetime — the same pattern as MCP server connections.
When the model calls the skill tool:
- The skill's markdown body is returned as structured XML output
- Auxiliary files in the skill directory (up to 10) are listed so the model can read them with other tools
- The base directory path is included so relative references resolve correctly
Since the skill tool is a regular tool, it emits the standard tool.start and tool.done events. It also goes through the approve callback if one is configured.
The discovery and tool creation functions are exported separately for full control:
import { discoverSkills, createSkillTool } from "@openharness/core";
const skills = await discoverSkills({ paths: ["./skills"] });
console.log(skills.map(s => s.name)); // inspect discovered skills
// Create the tool manually and pass it in
const agent = new Agent({
name: "dev",
model: openai("gpt-5.2"),
tools: { ...fsTools, bash, skill: createSkillTool(skills) },
});OpenHarness integrates with AI SDK 5's data stream protocol, so you can stream agent sessions directly to useChat-based React UIs.
Both Session and Conversation have two methods for streaming to the client:
toUIMessageStream(input)— returns aReadableStream<UIMessageChunk>that maps session events to AI SDK 5 typed chunkstoResponse(input)— wraps the stream in an HTTPResponsewith SSE headers, ready to return from any route handler
// app/api/chat/route.ts (Next.js)
import {
Agent, Conversation, toRunner, apply,
withTurnTracking, withCompaction, withRetry, withPersistence,
extractUserInput, type SessionStore,
} from "@openharness/core";
const store: SessionStore = {
async load(id) { return db.get(id); },
async save(id, messages) { await db.set(id, messages); },
};
const conversations = new Map<string, Conversation>();
function getOrCreateConversation(id?: string): Conversation {
const convId = id ?? crypto.randomUUID();
let conv = conversations.get(convId);
if (!conv) {
const runner = apply(
toRunner(agent),
withTurnTracking(),
withCompaction({ contextWindow: 128_000, model: agent.model }),
withRetry(),
withPersistence({ store, sessionId: convId }),
);
conv = new Conversation({ runner, sessionId: convId, store });
conversations.set(convId, conv);
}
return conv;
}
export async function POST(req: Request) {
const { id, messages } = await req.json();
const conv = getOrCreateConversation(id);
const input = await extractUserInput(messages);
return conv.toResponse(input);
}The stream emits all standard AI SDK 5 chunk types (text-delta, reasoning-delta, tool-input-available, tool-output-available, start, finish, etc.) plus custom OpenHarness data parts for subagent activity, compaction, retry, and turn lifecycle:
| Data part | Description |
|---|---|
data-oh:subagent.start |
A subagent task was spawned |
data-oh:subagent.done |
A subagent task completed |
data-oh:subagent.error |
A subagent task failed |
data-oh:compaction.start |
Compaction started |
data-oh:compaction.done |
Compaction finished |
data-oh:retry |
Retrying after transient error |
data-oh:turn.start |
Turn started |
data-oh:turn.done |
Turn finished |
data-oh:session.compacting |
Session is compacting |
The React package provides hooks that wire into AI SDK 5's useChat and track OpenHarness-specific state:
import {
OpenHarnessProvider,
useOpenHarness,
useSubagentStatus,
useSessionStatus,
} from "@openharness/react";
function App() {
return (
<OpenHarnessProvider>
<Chat />
</OpenHarnessProvider>
);
}
function Chat() {
const { messages, sendMessage, status, stop } = useOpenHarness({
endpoint: "/api/chat",
});
const { activeSubagents, hasActiveSubagents } = useSubagentStatus();
const session = useSessionStatus();
return (
<div>
{messages.map((msg) => (
<div key={msg.id}>
{msg.parts.map((part, i) =>
part.type === "text" ? <span key={i}>{part.text}</span> : null
)}
</div>
))}
<button onClick={() => sendMessage({ text: "Hello" })}>Send</button>
</div>
);
}useOpenHarness(config) — creates a chat session connected to your API endpoint. Returns the same interface as AI SDK 5's useChat (messages, sendMessage, status, stop, etc.), typed with OHUIMessage.
useSubagentStatus() — derives reactive state from data-oh:subagent.* events:
activeSubagents— currently running subagentsrecentSubagents— all subagents seen in this sessionhasActiveSubagents— boolean shorthand
useSessionStatus() — tracks turn index, compaction state, and retry info from data-oh:* events.
OpenHarnessProvider — context provider that holds subagent, session, and sandbox state. Wrap your app (or chat component) with this.
The Vue package provides the same functionality as composables for Vue 3 and Nuxt:
<script setup lang="ts">
import {
OpenHarnessProvider,
useOpenHarness,
useSubagentStatus,
useSessionStatus,
} from "@openharness/vue";
</script>
<template>
<OpenHarnessProvider>
<Chat />
</OpenHarnessProvider>
</template><script setup lang="ts">
import { ref } from "vue";
import { useOpenHarness, useSubagentStatus, useSessionStatus } from "@openharness/vue";
const chat = useOpenHarness({ endpoint: "/api/chat" });
const subagent = useSubagentStatus();
const session = useSessionStatus();
const input = ref("");
function send() {
const text = input.value.trim();
if (!text) return;
input.value = "";
chat.sendMessage({ text });
}
</script>
<template>
<div>
<div v-for="msg in chat.messages" :key="msg.id">
<template v-for="(part, i) in msg.parts" :key="i">
<span v-if="part.type === 'text'">{{ part.text }}</span>
</template>
</div>
<form @submit.prevent="send">
<input v-model="input" placeholder="Type a message..." />
<button type="submit">Send</button>
</form>
</div>
</template>useOpenHarness(config) — creates a chat session connected to your API endpoint. Returns an AI SDK 5 Chat instance with reactive properties (messages, status, sendMessage, stop, etc.), typed with OHUIMessage.
useSubagentStatus() — returns a computed ref deriving reactive state from data-oh:subagent.* events.
useSessionStatus() — returns a computed ref tracking turn index, compaction state, and retry info.
OpenHarnessProvider — renderless wrapper component that provides shared subagent, session, and sandbox state via Vue's provide/inject.
If you're building custom UI components that consume the stream directly, the core package exports typed data part types and guards:
import {
type OHDataPart,
isSubagentEvent,
isCompactionEvent,
} from "@openharness/core";| Example | Description | Run |
|---|---|---|
examples/cli |
Interactive terminal agent with tool approval, subagent display, and composed middleware | pnpm --filter cli-demo start |
examples/nextjs-demo |
Next.js chat app with streaming, @openharness/react, composed middleware, and announce tool |
pnpm --filter nextjs-demo dev |
examples/nuxt-demo |
Nuxt 4 chat app with streaming, @openharness/vue, composed middleware, and announce tool |
pnpm --filter nuxt-demo dev |
See Getting Started for setup instructions.