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.
- 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:
| What it is | Relation to eharness | |
|---|---|---|
AI SDK core (ai) |
Model calls, tools, streams, UI messages | eharness is built directly on it; no parallel types |
AI SDK Harnesses (@ai-sdk/harness) |
Adapter layer to run existing harness runtimes (Claude Code, Codex, ...) in a sandbox | Different layer: eharness is for building your own harness |
| eve (Vercel) | Filesystem-first, opinionated agent framework with Vercel-native durability | eharness is code-first, unopinionated about infrastructure and much thinner |
| Mastra | Full framework (workspaces, memory, workflows, deployment) | eharness is a library, not a framework: it owns no server, routes, auth or UI |
| LangChain Deep Agents | Harness on LangGraph with pluggable filesystem backends | Similar ideas (backends, composition); eharness has no graph runtime dependency |
More in concept (what eharness is and is not, principles, glossary).
npm install eharness ai zod # or: bun add / pnpm add
npm install @ai-sdk/mcp # only if you use eharness/mcpNode ≥ 22 or Bun. ESM only. ai and zod are peer dependencies; eharness has no runtime
dependencies of its own.
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.
Each snippet is small; the guide linked under it has the full version. Values marked
// yours: ... are your application's.
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?
| You have | Use | You get |
|---|---|---|
| One chat route in your own framework (Next, Hono, Express, ...) | handleChatRequest(...) from eharness (and handleResumeRequest(...) to resume across instances with a stream log) |
The useChat protocol on routes you write. Auth, ownership checks and every other route are yours. |
A whole server, or a browser using eharness/client |
createHarnessHttp(agent, { authorize }) from eharness/http (Draft) |
Chat, resume, steer, interrupt, session reads, attention queue and activity feed behind one authorize hook, with idempotency and status codes handled. A fetch(Request) handler. |
| A script, a test, a CLI | session.send(); read run.stream and await run.result |
No HTTP. |
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.
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.
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 })),
}).resultWith useChat, approvals need no server code: handleChatRequest calls respond(). Guides:
approvals and interaction,
permissions, approval guard.
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.
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 itGuides: writing a storage adapter, several instances, production patterns. More snippets (external waits, browser tools, guard, ledger, memory, background events) are in getting started.
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.
| Import | Status | Contents |
|---|---|---|
eharness |
Accepted (model catalog and cost: Draft) | defineHarnessAgent, definePlugin, wrapPlugin, defineSkill, defineSkillSource, defineToolSource, defineDataPart, defineMessageKind, handleChatRequest, handleResumeRequest, externalTool, toolTraits, createJudge, modelsDevCatalog, openRouterCatalog, lookupModel, computeCost, observeModel, isPrivateHost, errors, stored-text constants, types |
eharness/filesystem |
Accepted | FileSystem contract, filesystem() plugin (file tools, skills autoload), fsSkillSource(), checkpoints, projectInstructions() (Draft), helpers |
eharness/filesystem/memory |
Accepted | inMemoryFs() |
eharness/filesystem/node |
Accepted, Node-only | diskFs(), mountFs(), nodeWorkspace(), nodeCheckpointStore(), nodeRealpath() |
eharness/storage/memory |
Accepted | inMemoryMessages(), inMemoryState() (the default storage), inMemoryInbox(), inMemoryBudgetLedger(), inMemoryStreamLog() |
eharness/mcp |
Accepted | mcpServerSource(), remoteMcpSource(), listRemoteMcpTools(), bearerAuthProvider() (optional peer @ai-sdk/mcp) |
eharness/todos |
Draft | todos() plugin (todo_write, data-todos.list), latestTodos(), renderTodos(), openTodos() |
eharness/memory |
Draft | memory() plugin: memory files under app-chosen roots, pinned files, pre-compaction flush |
eharness/guard |
Draft | guard(): an LLM approval judge that can only tighten (deny or ask a person) |
eharness/group |
Draft | group() and routeGroupMessage(): should-respond gating, pending history, anti-loop |
eharness/openapi |
Draft | openApiSource(): OpenAPI 3.0 / 3.1 JSON as tools |
eharness/permissions |
Draft | permissions(), createPermissionEngine(): rules, modes, plan mode, shell command analysis |
eharness/subagent |
Draft | subagent() (agent tool; approvals inline / park / policy), subagentChild(), pendingSubagentApprovals(), reconcileSubagentWaits(), reconcileBackgroundChildren() |
eharness/ask |
Draft | ask() (ask_user_question), pendingQuestions(), answerOutput() |
eharness/web |
Draft | webFetch() (SSRF guards, Markdown) and webSearch() (provider-agnostic) |
eharness/web/node |
Draft, Node-only | nodeResolveHost(): a DNS resolver for webFetch |
eharness/code |
Draft | code() (run_python, install_packages): Python over the virtual file system with incremental sync and compare-and-set write-back, syncedSandbox() |
eharness/shell |
Draft, Node-only | shell() (bash, background tasks), localSandbox(), detectOsSandbox() |
eharness/providers |
Draft (0.8) | createProviderManager(), provider definitions, inMemoryProfileStore(), sealedProfileStore(), formSpec(), SecretVaults, catalog loaders |
eharness/connectors |
Draft (0.8) | createConnectorService(), defineConnector(), setup flows, enablement, activateConnectors(), built-in connectors |
eharness/activity |
Draft (0.8) | activity() plugin, ActivityStore port, SSE helpers, listAttention() / answerAttention() |
eharness/http |
Draft (0.8) | createHarnessHttp(): a fetch(Request) handler for chat, resume, sessions, attention and activity behind one authorize hook; openAccess |
eharness/client |
Draft (0.8) | createHarnessClient(), createHarnessTransport(), createChatSession(), createActivityFeed(), createAttentionList() |
eharness/client/react |
Draft (0.8) | createReactBindings(React), chatBridgeOf() |
eharness/testing |
Accepted | scriptedModel(), scriptedProvider() and conformance suites for your adapters |
The full table with source files is in architecture.
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.
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/.
| Example | Shows |
|---|---|
quick-start.ts |
the code above |
tool-context.ts |
a tool that reads the running chat and streams custom data parts |
basic-cli.ts |
terminal rendering with readUIMessageStream, filesystem plugin |
next-route.ts |
Next.js routes and a useChat client (not runnable alone) |
next-route.demo.ts |
the offline demo of next-route.ts: bun examples/next-route.demo.ts (approvals, resume) |
structured-output.ts |
a typed, validated final answer with send(…, { output }): tool mode and native mode |
wrap-plugin.ts |
wrapPlugin(): per-request configuration of guard() (judge per request, trusted requests, decision log) |
plugin-authoring.ts |
a plugin with a service, tool, data part, hooks and state |
long-running.ts |
the progress guard ('stuck') and the wrap-up step ('max-steps') |
context-prune.ts |
view-only pruning of old tool outputs (compaction.prune) and the thrash stop ('context-thrash') |
budget-and-cost.ts |
modelsDevCatalog, costUsd per step/turn/session, nested addUsage, USD budgets ('cost-cap') |
risk-approvals.ts |
tool risk, approval.risk, an inbox from pending, respond() with actor, approval.decided audit |
todos.ts |
the todos() plugin with enforce, rendering data-todos.list, latestTodos() |
compaction-flush.ts |
pre-compaction flush: compaction.before, memory flushOnCompaction, the eh.flush audit record |
memory.ts |
the memory() plugin: per-user root, read-only org root, pinned profile, onWrite audit |
background-events.ts |
a job result as a message kind, inject(…, { wake: true }), a heartbeat that ends silently |
inbox.ts |
two instances sharing a durable inbox: steer, collect, Stop across instances |
remote-abort.ts |
abort() from another instance through the state (setIf) |
external-wait.ts |
a build tool parks the turn; a webhook in another instance resolves it; a timeout through the inbox |
client-tools.ts |
request-declared client tools and page context, validation, timeouts |
approval-guard.ts |
an LLM judge denies a prompt-injected exfiltration and escalates an odd call |
group-chat.ts |
two humans and another bot in one chat: mention gating, missed history, loop limit |
openapi-tools.ts |
an OpenAPI document as tools: curation, base URL, auth header, risk from the method |
subagent-tool.ts |
a tool that runs a child session, streams its progress, reports usage |
json-file-storage.ts |
MessageAdapter + StateAdapter on JSON files |
postgres-storage.ts |
Postgres adapters + advisory-lock SessionLock. Needs DATABASE_URL (skips otherwise). |
postgres-inbox.ts |
Postgres InboxAdapter (FOR UPDATE SKIP LOCKED, LISTEN/NOTIFY). Needs DATABASE_URL (skips otherwise). |
postgres-budget-ledger.ts |
Postgres BudgetLedger: monthly scopes, atomic reservations, conformance. Needs DATABASE_URL (skips otherwise). |
custom-fs-adapter.ts |
a FileSystem over a key-value store |
team/ |
a team of agents: Bun + Hono + SQLite server and a SvelteKit web app; approvals inbox, routines, connectors (bun run dev in examples/team) |
coder/ |
a terminal coding agent (Ink UI): permissions, subagents, todos, compaction, JSON storage |
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
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.
| eharness | ai | zod | @ai-sdk/mcp (optional) | Runtime |
|---|---|---|---|---|
| 0.8.x | ^7.0.137 | ^3.25.76 || ^4.1.8 | ^2.0.73 | Node ≥ 22, Bun |
| 0.7.x | ^7.0.127 | ^3.25.76 || ^4.1.8 | ^2.0.66 | Node ≥ 22, Bun |
| 0.6.x | ^7.0.127 | ^3.25.76 || ^4.1.8 | ^2.0.66 | Node ≥ 22, Bun |
| 0.5.x | ^7.0.127 | ^3.25.76 || ^4.1.8 | ^2.0.66 | Node ≥ 22, Bun |
| 0.4.x | ^7.0.127 | ^3.25.76 || ^4.1.8 | ^2.0.66 | Node ≥ 22, Bun |
| 0.3.x | ^7.0.123 | ^3.25.76 || ^4.1.8 | ^2.0.63 | Node ≥ 22, Bun |
| 0.2.x | ^7.0.123 | ^3.25.76 || ^4.1.8 | ^2.0.63 | Node ≥ 22, Bun |
| 0.1.x | ^7 (≥ 7.0.104 needed in practice) | ^3.25.76 || ^4.1.8 | ^2 | Node ≥ 22, Bun |
A new AI SDK major needs a new eharness minor (0.x) / major (≥ 1.0).