eharness
Build your own agent harness on the Vercel AI SDK v7: plugins, context, messages, streaming, sessions and compaction, with storage you plug in.
Every agent built on AI SDK ends up writing the same harness around streamText: a step loop,
prompt assembly, skills, MCP, history that survives restarts, compaction, approvals, a UI stream.
eharness is that skeleton as a library. You keep your server, auth, database and domain logic;
it standardises the parts every harness reimplements and stays out of the rest.
Status. The contracts in docs/specs are implemented and tested. Before 1.0,
breaking changes ship only in minor versions with a migration note in the
changelog and in Upgrading
(API stability). Modules marked Draft below may change in a
minor release.
Why
- Plugins and typed context. Tools, skills, instructions, hooks, services and UI data parts bundle into plugins. Declare them as constants or load them at runtime (per user, per turn, from a database, from MCP); the agent does not care which.
- AI SDK all the way down. The stream is the AI SDK UI message stream (it works with
useChatas is), messages areUIMessages, tools aretool(), models areLanguageModel. No parallel types. - Storage is yours. A two-method
MessageAdapter, aStateAdapter, an optional durable inbox. The library ships memory adapters and conformance suites, so your Postgres, JSON or Redis adapter is provably correct. - Built for long, interactive, multi-instance turns. 500 steps by default with a progress guard, fixed compaction, USD budgets, risk-based approvals, steering, crash recovery, locks and a Stop button that reaches the instance running the turn.
- Product building blocks, all optional. Filesystem, shell, permissions, subagents, memory, todos, an LLM approval guard, OpenAPI tools, providers, connectors, an activity feed, an HTTP module and a browser client.
How it compares:
More in concept (what eharness is and is not, principles, glossary).
Install
npm install eharness ai zod # or: bun add / pnpm add
npm install @ai-sdk/mcp # only if you use eharness/mcp
Node ≥ 22 or Bun. ESM only. ai and zod are peer dependencies; eharness has no runtime
dependencies of its own.
Quick start
import { tool } from 'ai'
import { defineHarnessAgent, defineSkill } from 'eharness'
import { filesystem } from 'eharness/filesystem'
import { inMemoryFs } from 'eharness/filesystem/memory'
import { z } from 'zod/v4'
const agent = defineHarnessAgent({
model: 'anthropic/claude-sonnet-4.6', // AI Gateway id or any AI SDK model
contextWindow: 200_000,
instructions: 'You are a careful assistant. Keep notes as files under /notes.',
tools: {
get_time: tool({
description: 'Current time as an ISO string',
inputSchema: z.object({}),
execute: async () => new Date().toISOString(),
}),
},
skills: [
defineSkill({
name: 'note-style',
description: 'Use before writing a note.',
content: 'Bullets only.',
}),
],
plugins: [filesystem({ fs: inMemoryFs() })],
})
const session = agent.session('demo')
const run = session.send('Write the current time to /notes/now.md')
for await (const chunk of run.stream) {
if (chunk.type === 'text-delta') process.stdout.write(chunk.delta) // or, in a route: await run.toResponse()
}
const result = await run.result // never rejects: errors end the turn with stop: 'error'
console.log(`\n${result.stop} after ${result.steps} steps`)
if (result.stop === 'error') console.error(result.error?.code, result.error?.message) // e.g. EH_PROVIDER_AUTH: set AI_GATEWAY_API_KEY
console.log(`${(await session.messages()).length} messages stored`)
The model string goes through the AI Gateway
(AI_GATEWAY_API_KEY); any AI SDK provider model (anthropic('claude-sonnet-4-6'), ...) works too.
This code is examples/quick-start.ts; bun examples/quick-start.ts
runs it offline with a scripted model. Walkthrough: getting started.
Terms such as turn, step, kind message and commit point are in the
glossary.
Tour
Each snippet is small; the guide linked under it has the full version. Values marked
// yours: ... are your application's.
Serve it to useChat
import { handleChatRequest } from 'eharness'
import { agent } from './agent' // yours: the agent from the quick start
declare function authenticate(req: Request): Promise<{ id: string }> // yours
declare function userOwnsChat(userId: string, chatId: string): Promise<boolean> // yours
// POST /api/chat: send, approval answers, regenerate and edit in one route
export async function POST(req: Request): Promise<Response> {
const user = await authenticate(req) // eharness knows only the session id
const body = await req.json() // useChat's { id, messages, trigger, messageId }
if (!(await userOwnsChat(user.id, body.id))) return new Response(null, { status: 404 })
return await handleChatRequest(agent.session(body.id), body, {
runtime: { userId: user.id }, // per-request values go into the turn, not the cached session
}).toResponse() // waits until the turn is accepted, so a failure gets its HTTP status
}
// GET /api/chat/[id]/stream: useChat({ resume: true }) replays the running turn
export async function GET(
req: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const { id } = await params
const user = await authenticate(req)
if (!(await userOwnsChat(user.id, id))) return new Response(null, { status: 404 }) // same check
const run = agent.session(id).attach() // the running turn, or undefined when idle
return run ? run.toStreamResponse() : new Response(null, { status: 204 })
}
On the client: useChat<InferHarnessUIMessage<typeof agent>>() types every data part, tool and
message kind. Runnable: examples/next-route.ts (offline demo:
bun examples/next-route.demo.ts). Guides: HTTP server,
getting started.
Which one: handleChatRequest or createHarnessHttp?
In a POST route use await run.toResponse(): a failure before the turn is accepted (busy, bad
input) answers 409 / 400 / 503. run.toStreamResponse() streams at once and carries such
failures inside a 200 stream; use it for resume only.
Tools that know the chat
Write a tool as (ctx) => tool() to get the session, the turn, your runtime values and state, and
a typed writer for custom UI data parts:
import { tool } from 'ai'
import { defineDataPart, defineHarnessAgent } from 'eharness'
import { z } from 'zod/v4'
const agent = defineHarnessAgent({
model: 'anthropic/claude-sonnet-4.6',
dataParts: {
invoice: defineDataPart({ schema: z.object({ total: z.number(), status: z.string() }) }),
},
tools: {
create_invoice: (ctx) =>
tool({
description: 'Create and send an invoice',
inputSchema: z.object({ total: z.number() }),
execute: async ({ total }, { toolCallId }) => {
ctx.state.set('lastInvoice', total) // persisted with the session
ctx.stream.data('invoice', { total, status: 'sent' }, { id: toolCallId }) // typed
return `Invoice sent for ${String(ctx.runtime.userId)}` // agent.session(id, { runtime })
},
}),
},
})
Guides: getting started,
rendering data parts. Runnable:
examples/tool-context.ts.
Risk-based approvals
Tools declare a risk; the agent decides which risks need a person. respond() records who
answered, and one hook sees every decision:
import { tool } from 'ai'
import { defineHarnessAgent } from 'eharness'
import { z } from 'zod/v4'
const agent = defineHarnessAgent({
model: 'anthropic/claude-sonnet-4.6',
tools: {
delete_record: tool({
inputSchema: z.object({ id: z.string() }),
metadata: { risk: 'destructive' }, // 'read' | 'write' | 'destructive' | 'external'
execute: async ({ id }) => `Deleted ${id}`,
}),
},
approval: { risk: { destructive: 'user-approval', unknown: 'user-approval' } },
})
const session = agent.session('ops-1')
const { pending } = await session.send('Delete record r1').result // stop: 'tool-pending'
await session.respond({
approvals: (pending?.approvals ?? []).map((p) => ({ id: p.approvalId, approved: true })),
}).result
With useChat, approvals need no server code: handleChatRequest calls respond(). Guides:
approvals and interaction,
permissions, approval guard.
Long turns, cost and budgets
Turns run up to 500 steps. A progress guard stops a turn that repeats itself or keeps failing
(stop: 'stuck'), and when the step budget runs out the model gets one tool-less step to
summarize. With prices, every turn records its estimated cost and USD budgets are enforced:
import { defineHarnessAgent, modelsDevCatalog } from 'eharness'
declare const modelsDevJson: unknown // yours: models.dev's api.json, fetched and cached by your app
const agent = defineHarnessAgent({
model: 'anthropic/claude-sonnet-4.6',
models: modelsDevCatalog(modelsDevJson), // context window and prices (or your own record)
budget: { maxTurnUsd: 2, maxSessionUsd: 20 }, // used up: stop 'cost-cap'
})
const result = await agent.session('job-1').send('Migrate the test suite').result
console.log(result.stop, result.usage.costUsd) // 'complete' | 'stuck' | 'max-steps' | 'cost-cap' | ...
Guides: long-running turns, models and cost, todos.
Storage and several instances
import { defineHarnessAgent } from 'eharness'
import type { InboxAdapter, MessageAdapter, StateAdapter } from 'eharness'
declare const messages: MessageAdapter // yours: Postgres, JSON files, ...
declare const state: StateAdapter // yours
declare const inbox: InboxAdapter // yours (optional): queue, steer, wake and abort across instances
const agent = defineHarnessAgent({
model: 'anthropic/claude-sonnet-4.6',
storage: { messages, state, inbox },
recovery: { instanceId: 'web-1' }, // a stable id: a restarted process recovers its turns at once
})
const session = agent.session('chat-1')
await session.enqueue('One more thing...', { mode: 'collect' }) // any instance
await session.abort() // stops the turn even when another instance runs it
Guides: writing a storage adapter, several instances, production patterns. More snippets (external waits, browser tools, guard, ledger, memory, background events) are in getting started.
Entry points
Every package.json#exports entry. Draft means all symbols of the subpath are experimental and
may change in a minor release (API stability). Node-only
subpaths import node: built-ins; everything else is runtime-neutral.
The full table with source files is in architecture.
Deployment profiles
Every shipped module serves three product shapes: an autonomous server (no human; policy and budgets), a single-process interactive app (CLI or TUI), and a split web/server app (pending state in storage, answers from any instance). See ADR-0034.
Examples
Every example runs offline (bun examples/<file>) and is typechecked and executed in CI, except
next-route.ts (route handlers: run next-route.demo.ts instead), the Postgres examples (they
need DATABASE_URL and skip otherwise) and the two apps, team/ and coder/.
Guides
Start with which module do I need? (the 33 entry points in three groups, with stability labels), then getting started and the guide index (every guide, one line each). Often needed:
- Build: instructions, tools and MCP · writing a plugin · writing a storage adapter · skills · filesystem
- Interactive and safe: approvals and interaction · permissions · approval guard · LLM judge
- Long-running and scaled: long-running turns · models and cost · several instances · observability
- Serve and render: HTTP server · web client · rendering data parts
- Ship: production patterns · production checklist · testing · reference · upgrading to 0.7 and 0.8
Reference: concept (incl. glossary) · architecture · specs (the contracts) · decisions (ADRs) · contributing
Security
eharness checks message shapes, approvals and file paths; identity and authorization are yours.
Check session ownership in every route (including the resume GET), pass per-request identity
in SendOptions.runtime, add per-user quotas on top of the per-session budgets, and set
toolErrorText and logger before production. The full list of design-level risks, defaults and
what to do: production patterns → security.
Compatibility
A new AI SDK major needs a new eharness minor (0.x) / major (≥ 1.0).