@botiverse/oar
v0.66.0
Published
A programming interface for all agent harnesses: provider-independent contracts for controlling and observing agent runtimes
Keywords
Readme
@botiverse/oar
Provider-independent TypeScript contracts and built-in implementations for controlling and observing Antigravity, Claude, Codex, Cursor, Grok, Kimi, OpenCode, Pi, and Pi Durable.
import { defaultRuntimes, promptAndWait } from "@botiverse/oar";
const runtime = defaultRuntimes.require("grok");
const installation = await runtime.installation?.();
if (installation?.kind === "available") {
const session = await runtime.session(installation, { cwd: process.cwd() });
session.events((event) => {
switch (event.kind) {
case "text_delta": process.stdout.write(event.text); break;
case "tool_call_started": console.log(`[${event.tool}]`); break;
case "turn_ended": console.log(event.outcome.kind); break;
}
}, { coalesceText: true });
const run = await promptAndWait(session, "Inspect this repository");
console.log(run.kind === "rejected" ? run.reason : run.outcome);
console.log(session.usage(), await runtime.accountUsage?.(installation));
await session.dispose();
}promptAndWait uses your inputId, or generates a UUID, to associate native
queued/started reports with its answer. A notification turn before the input
starts does not satisfy that wait. Without those native reports it keeps the
request-based fallback. For a lower-level wait, pass the inputId to
awaitTurnEnd(session, requestSeq, inputId); omitting it waits for the first
root turn end after that cursor. session.prompt() already generates an id
when omitted; it is recorded on the returned prompt request. A native
before-start cancellation resolves an input-scoped wait as aborted, even
without a turn-end event. awaitIdle waits through an intervening notification
while a host prompt remains pending.
session.events() delivers flat, attributed Events (native user message
echoes, text, reasoning, tool call start / later input / progress / end, turn
start and end, usage, model, effort, compaction start / end, retry, background
tasks and subagents started, updated and ended, runtime→app requests and oar's
answers, control rejections, withdrawn inputs, the process exit), each carrying
the seq and agentPath of the record it was read from. Kinds a runtime never
says (claude has no compaction start, ACP runtimes and cursor no compaction,
pi and pi-durable report retry) never appear; the runtime pages say which. It is a
projection over the record stream, which session.rawEvents() and
session.records() expose (RawEvent: Frame with the native payload
verbatim, RequestRecord, ResponseRecord) for consumers who need the
runtime's own frames.
Tool arguments can arrive while the model is still writing them. Append
tool_call_input_delta.delta as raw text; tool_call_input.input replaces
the whole input when complete. SessionView does this for you, marking a
tool's preview with inputPartial: true until the complete input arrives.
Claude's streamed tool_call_started carries no input, so read the later
input event before parsing arguments. Calls without a partial stream still
start with their complete input.
session.deliver(input, { when, origin }) sends input the host produces (a
subagent's result, a finished job) at the right moment: a new turn when the
session is idle, so the agent wakes, otherwise steered into the turn or queued
behind it. origin is recorded so a UI can tell it from typed input.
session.steer exists only on a session whose runtime can steer (kimi and
antigravity sessions have none), so a host offers a steer control by reading
the member, never a runtime name; deliver and steerOrQueue queue where it
is absent. session.withdraw(inputId) takes a queued input back before it is
sent (accepted), or answers not_queued once it went; with it a UI offers
withdraw, edit (withdraw, then queue again) and send now (withdraw, then
deliver). It exists where OAR holds the queue itself (claude, pi, cursor
and the ACP runtimes), and on pi-durable through native submission cancellation, not on codex.
session.resources() reads the memory a runtime takes on the host: resident
bytes and a process count over the runtime process, its group and its
descendants, null once it has exited (and on Windows). It is a reading only,
never a handle: no pid leaves OAR. Sessions whose runtime runs in the host
process (pi, pi-durable, cursor) have none.
For conversation UIs, use the browser-safe reduceConversation projection
over session.rawEvents(). It joins input requests, responses and native
echoes by identity, including steer → queue fallback. See the
conversation contract.
failureAdvice suggests when to retry, based only on the failure class.
It does not establish that repeating an input or an entire turn is safe:
tools may already have run and files may already have changed. A host can
collect the evidence for its own recovery decision:
import { failureAdvice, type ConversationInput, type FailureClass, type Session } from "@botiverse/oar";
function recoveryEvidence(failure: FailureClass, input: ConversationInput, reopened: Session) {
return {
timing: failureAdvice(failure),
inputState: input.state,
dropReason: input.state === "dropped" ? input.reason : undefined,
recordsAfterReopening: reopened.records(),
};
}An accepted input should not be resubmitted without a later drop. Even
dropped with reason: "runtime_exited" means only that no input echo was
observed before exit; a durable queue may still hold it. Inspect the reopened
session's records and what the runtime can restore before deciding to resend.
See dropped input.
Public exports
The package has these public entry points, plus @botiverse/oar/community for community runtimes (below):
@botiverse/oar: the runtime registry and adapters,defineRuntime,UnsupportedOptionError,RuntimeFailureError,SessionNotFoundError,SessionUnavailableError, theoar-voyage/3recorder (openVoyage), and everything the brands and observe entry points below export. Node-only (adapters importnode:child_processand runtime SDKs).@botiverse/oar/browser: portable contracts, registry, session kernel, observe helpers, agent report formatting, and branding. No Node adapters, process helpers or local image-file loaders.@botiverse/oar/brands: browser-safe runtime names and SVG icons.@botiverse/oar/runtimes/<id>(claude,codex,pi,cursor,opencode,kimi,grok,antigravity): one built-in runtime (claudeRuntime, …) without the others, for a host that needs only some of them. Each entry stays at about 1 to 2.5 MB of heap where the root entry takes about 50 MB: the ACP SDK (about 20 MB) loads only when an ACP runtime first opens a session, lists models (kimi, grok, antigravity) or reads grok's usage, and the pi and cursor SDKs when theirs first run. Node-only.@botiverse/oar/observe: the browser-safe pure derivations overRawEvents andEvents (eventsOf,graphOf,usageOf,coalesceText,observeAgent,reduceStatus,tasksOf,observeStalls,classifyTool,reduceConversation,viewOf, …) with no Node or adapter imports, so a browser or Electron-renderer bundle can import it directly. The root export re-exports all of them. It also suppliesfailureAdvice(recovery timing perFailureClass) andnoticeText,noticeTone,phaseLabel,failureTextandtaskStatusLabel, alongsidetoolActionLabelandtoolGroupSummary: English display wording for OAR types, usable in JavaScriptCore too. Wording may change in a minor release; hosts must use the typed values for decisions, not parse the text (display wording).@botiverse/oar/kernel: the runtime-author SPI.createSessionKernelis the record stream every built-in adapter is built on (denseseq, cursor replay, control recording, the reachability rule) andsealSessionderives theSessionAPI face over an adapter. Pair withdefineRuntimeto ship a custom runtime (a scripted runtime for a host's tests, an in-process agent) without re-implementing the stream contract.inputImagesRefusalandwithInputImagesare the image rules every built-in runtime keeps, so a custom runtime refuses the inputs they refuse.withdrawHeldis the decision behindwithdrawfor a runtime that holds its own queue.@botiverse/oar/agents: subagents.createSubagents()starts child sessions on any runtime that takesSessionOptions.env(not cursor), returns a report for every turn they end, takes follow-ups, enforces depth and concurrency limits, and reports each child as a task. The host takes reports withwait/nextor receives them through anonReporthook; withformatReportandreportOriginit candeliverone into a parent session so it wakes. See subagents. Node-only.@botiverse/oar/agents/report:formatReport,parseReport,reportOriginand their types, with no Node or adapter imports, for a host forwarding or displaying reports.parseReportreads the existing report format into header fields, a structured outcome and the original body, or returnsnull.@botiverse/oar/agentsloads every built-in runtime, becausecreateSubagentsdefaults to them.@botiverse/oar/testing:scriptedRuntime({ turn }), a ready-made runtime on the kernel SPI whose model is a script. It yields a realSession(same records, folds and control semantics) with no binary, login or provider, for hosts' tests and demos. A script can also start tasks (turn.task) that end after the turn, like a background command. Node-only.
Brand SVG files are exported at @botiverse/oar/assets/brands/<runtime-id>.svg. Any other deep import (@botiverse/oar/dist/..., source paths) is internal and may break without notice.
Antigravity, Grok, Kimi, and OpenCode share an internal ACP v1 transport and session kernel, but only their concrete runtime identities are public. The registry deliberately does not expose a generic acp runtime. Pi and Cursor run in the host process through their SDKs. The pi SDK (@earendil-works/pi-coding-agent) is a dependency of this package. @cursor/sdk is an optional peer dependency, and cursor is not in defaultRuntimes: install the SDK (npm install @cursor/[email protected]) and hand it over, createRuntimeRegistry([...defaultRuntimes.list(), createCursorRuntime({ sdk: () => import("@cursor/sdk") })]). Written in your code, that import fails your compile if the package is missing. It is Cursor's own package under Cursor's terms, and it signs in with CURSOR_API_KEY or its own Cursor.auth.login().
Community runtimes are contributed and maintained outside the core team (each maintainer is named on its runtime page). They ship in this package from a separate entry point, @botiverse/oar/community, and are not in defaultRuntimes: createRuntimeRegistry([...defaultRuntimes.list(), createMorphRuntime()]) adds Mister Morph, driven through its Console Runtime API. The oar CLI includes them.
Pi 1.0.3 Azure migration: Pi renamed its provider from
azure-openai-responses to azure. Update that prefix in native provider
configuration and SessionOptions.model. When resuming an old Azure session,
explicitly pass model: "azure/<model>"; without it, Pi can retain the
conversation while selecting a different default model. The opening model
event and Session.model() report the effective selection. OAR's ^1.0.2
dependency range already permits this upstream update. See the
migration evidence and limits.
The command-line interface is a separate package: @botiverse/oar-cli.
What a runtime cannot honor is refused, never dropped: runtime.session()
rejects with an UnsupportedOptionError (option names the refused
SessionOptions key, the message is the reason), so a host can try and fall
back on it. runtime.refusedSessionOptions names the options a runtime
refuses at open, each with the reason, so a host can leave them out before
opening. One key names a value rather than an option: mcpServerCredentials,
an mcpServers entry carrying env or headers (as every serveMcpTools
entry does), refused by antigravity; leave such entries out. Some refusals
depend on the value or operation: Kimi and OpenCode
refuse a resume in another directory than the session's own (option:
"cwd"). See
refused session options.
A missing resume target rejects with SessionNotFoundError on verified native
signals, carrying sessionId and a redacted cause: { method, native }.
Other failed opens retain their own errors. Missing is scoped to the runtime's
configuration and, for Pi/Grok, the supplied cwd; save and reuse the cwd with
the id. See missing resume targets.
A target the runtime keeps but will not open now rejects with
SessionUnavailableError, whose reason is archived (the user archived it
in their app; OAR never unarchives it) or in_use (another process, such as
the user's app, holds it); codex reports both. See
unavailable resume targets.
SessionOptions.name is the session's display name in the runtime's own UI
(claude, codex and pi; other runtimes refuse it). A new session gets it; a
resume never replaces a name the session has. See
session name.
SessionOptions.additionalDirectories adds native workspace paths, resolved
relative to cwd. Claude, Codex, Cursor, Kimi (new sessions only), and
OpenCode v2 support it; other runtimes refuse it. Empty or omitted adds no
override. It keeps the primary cwd and does not change the sandbox mode.
Project rules or instruction files in those directories may or may not load;
this option does not guarantee their loading. Resume and configuration rules differ by runtime; see
additional directories.
SessionOptions.outputSchema supplies a JSON Schema object for final output.
Claude and Grok report their native validated value on a completed
TurnOutcome.structuredOutput; promptAndWait, status and SessionView
preserve it. Codex sends the schema to its provider but returns text only;
OAR never parses or validates that text. Give the schema again on resume.
import { defaultRuntimes, promptAndWait } from "@botiverse/oar";
const runtime = defaultRuntimes.require("claude");
const installation = await runtime.installation?.();
if (installation?.kind !== "available") throw new Error("Claude unavailable");
const session = await runtime.session(installation, {
cwd: process.cwd(),
outputSchema: {
type: "object", properties: { answer: { type: "integer" } },
required: ["answer"], additionalProperties: false,
},
});
try {
const run = await promptAndWait(session, "What is 2 + 2?");
if (run.kind !== "rejected" && run.outcome.kind === "completed" && "structuredOutput" in run.outcome) {
console.log(run.outcome.structuredOutput);
}
} finally { await session.dispose(); }Even with a schema, Claude may complete without structuredOutput; check
the field's presence as well as the outcome. Other core runtimes refuse
outputSchema at open. Where each runtime applies the schema, and how it
fails: structured output.
SessionOptions.mcpServers attaches MCP servers (stdio {name, command,
args?, env?} or streamable HTTP {name, type: "http", url, headers?}) to
one session, on top of the runtime's own: claude reads them from a 0600
FIFO that never puts them on a disk (a temporary file on Windows), codex
from its thread config overrides, grok, kimi, opencode and antigravity from
ACP session/new / session/resume, pi from an extension that registers
them; cursor and pi-durable refuse them. Give them again on resume. Their env and headers values never
appear in a record or an error; antigravity, which would store them in its
conversation database, refuses an entry carrying them. See the
channels
and each runtime page's "Session MCP servers".
Tools the host implements itself go through the same option. serveMcpTools
serves them from an MCP server inside the host process and returns the
mcpServers entry to pass, so one implementation reaches every runtime that
takes an http entry with headers (all but cursor, pi-durable and antigravity):
import { serveMcpTools } from "@botiverse/oar";
const served = await serveMcpTools({
name: "workspace",
tools: [{
name: "read_note",
description: "Read a note from the host's store",
inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] },
handler: async ({ id }, { signal }) => await notes.read(String(id), { signal }),
}],
});
const session = await runtime.session(installation, { cwd, mcpServers: [served.server] });
// ... later, after the sessions that use it are disposed:
await served.close();It listens on 127.0.0.1 behind a random bearer token (in the entry's
headers, so it never appears in a record), so the runtime must run on this
machine, as OAR's runtimes do. A handler returns text, or MCP content with
isError for a failed call; a thrown error becomes an isError result with
its message. Its signal aborts when the runtime cancels the call or the
server closes. One served set can back several sessions. instructions (guidance
across the tools) goes out as the MCP server instructions; claude, codex,
opencode and grok show them to the model, pi and kimi do not.
served.setTools(tools) replaces the tools while sessions run and sends MCP's
notifications/tools/list_changed. Whether a running session's model sees
the change is the runtime's choice, declared before opening as
runtime.mcpToolsListChanged: full (pi, opencode), additions (claude:
new tools appear, removed ones stay offered) or none (codex, kimi, grok); a
session that does not take it gets the new tools when the host opens its next
session. The declaration covers any MCP server that announces a change, so
antigravity declares full too, though a served entry never attaches there. See
host-served tools.
runtime.listModels(installation, options?) lists the models an installation
can run now (ok, unauthenticated or unsupported); every built-in runtime
has it. Entries include native effortLevels and serviceTiers where supported.
default: true marks the model a session uses when no model is set, where
the runtime says so (codex's configured model, else its catalog default; a new
session's current model on grok, kimi and antigravity; the default saved in
pi's settings). claude, cursor and opencode mark none: OAR never picks one.
Where several providers serve models (pi, opencode), provider: { id,
displayName? } says which, so a host never parses id. A project's own
configuration can change the list and its default, so pass options.cwd, the
directory a session would run in (the host's cwd by default). Like the
inventory readers, listing starts the runtime there the way a session would
and can run that workspace's code (pi loads the project's extensions), so do
not list in a directory you do not trust.
SessionOptions.serviceTier applies at open (also on resume) and is checked
against the runtime's own report. Use default to explicitly clear a tier;
it is not a model catalog entry. Session.serviceTier() reports the effective
tier. Unsupported runtimes reject the option before opening.
runtime.accountUsage(installation) reads account quota on claude,
codex, grok and kimi; its failure semantics are in the
account usage reference.
runtime.checkUpdate(installation) reports the version the runtime's own
updater would install and where that answer came from;
runtime.upgrade(installation) runs that updater without a terminal and
judges the result by the version the same executable reports afterwards,
never by its exit code. oar never upgrades on its own. Claude, Codex, Grok
and Kimi have both; Antigravity has only the check; OpenCode has neither
yet; Pi and Cursor have neither, their SDK versions following oar's (Pi's
SDK is a dependency, Cursor's an exact peer dependency).
See runtime updates.
runtime.installPlan() reports what installing a runtime would run on this
machine (the vendor's documented installer and the page that documents it)
or why it would run nothing (platform, requires_privileges,
requires_gui, missing_tool; requires_privileges and missing_tool
still carry the vendor's steps and source, for the person to run
themselves once they have the rights or the tool); runtime.install() runs
it, without a terminal and never with sudo, when installation() finds no
copy, and judges the result by installation() afterwards, never by the
installer's exit code. oar never installs on its own. Claude, Codex, Grok,
Kimi and OpenCode install through their vendors' macOS and Linux scripts;
OpenCode declares two release lines in runtime.installLines (v1, v2)
and takes the host's choice as line (oar picks none), and a copy of either
line is already_installed, never replaced. Antigravity's plan is
requires_gui; Pi and Cursor have neither member.
See runtime install.
runtime.login(installation, interaction) logs an installation in through
the runtime's own login, without a terminal: the sign-in URL or device code
arrives as a ProviderLoginInteraction event, and a code the person pastes
back is answered through its manual_code prompt and written to the
runtime's stdin only. It never logs the current account out first, and
interaction.signal cancels it. runtime.authStatus(installation) reads the
runtime's local status query. Claude, Codex and Cursor (through its SDK's
own Cursor.auth) have both; Antigravity has neither, since its terms do not
allow signing in through a third-party tool. runtime.logout(installation)
signs those three out through their own logout (claude auth logout,
codex logout, Cursor.auth.logout) and resolves logged_out when
authStatus then reads logged out (or, when it cannot tell, on the
runtime's own report of success); an API key in the environment is not
touched, and one the status still reads is failed / still_logged_in.
See
runtime login.
Images go with an input through InputOptions.images ({ path } entries:
absolute paths to png, jpeg, gif or webp files) on prompt, steer and queue,
as the runtime's own image content. session.capabilities.images says whether
the runtime takes them; an input it cannot deliver is rejected whole. See
Images.
TypeScript hosts: keep skipLibCheck on. With skipLibCheck: false and module: nodenext, importing @botiverse/oar alone reports errors inside the pi SDK's own declarations (@earendil-works/pi-ai imports JSON without an import attribute, TS1543); OAR's own declarations check clean. A host that installs @cursor/sdk and checks library files also needs the DOM lib for its @connectrpc/connect dependency (HeadersInit).
Host-owned durable sessions
Install the optional native peers in the host: npm install @earendil-works/pi-durable@~1.2.0 @earendil-works/chord@~1.2.0.
The factory lives only in @botiverse/oar/pi-durable, usable in Node and browsers.
The root and browser core entries load without these peers.
import { viewOf } from "@botiverse/oar/browser";
import { createPiDurableRuntime } from "@botiverse/oar/pi-durable";
// Use the same Models instance that the host passed to Harness.open.
const runtime = createPiDurableRuntime({ harness, models });
const session = await runtime.session({ kind: "available", via: "bundled" }, {
cwd: "/workspace", model: "provider/model",
});
session.rawEvents(() => render(viewOf(session.records())));
await session.prompt("Hello", { inputId: crypto.randomUUID() });The host owns its Pi Durable 1.2.0 Harness, providers, credentials, extensions
and storage. resume: session.id attaches to that storage's conversation and
resumes unfinished work. Queue and input-id deduplication are native and
durable. dispose() detaches the observer; it does not stop work or close the
Harness. Use abort() to stop a run. The portable entry needs Web Crypto.
See Pi Durable
for configuration, snapshot and recovery semantics. This constructor is not
in the default registry or CLI.
Native inventories
Every runtime exposes skills(installation, options?),
commands(installation, options?), mcpServers(installation, options?), and tools(installation, options?).
Options are { cwd?: string, timeoutMs?: number }; cwd defaults to
process.cwd(). Queries discover native information independently: they do
not inspect an existing Session or submit a model prompt, though native
startup can load extensions and connect configured MCP servers.
Results distinguish ok, unsupported, and unavailable. Successful results
contain items, scope.cwd, observedAt, view, and partial. A
mcp-only view excludes built-in tools. Unknown schema or state fields stay
absent, and an unsupported query never pretends to be an empty catalog.
Codex and Claude expose skills and MCP discovery (tools are MCP-only); Grok exposes independent skills and MCP configuration discovery; Pi exposes skills and registered tools with active membership. Pi commands include extension commands and prompt templates; Claude commands come from native initialize. Other core runtimes return unsupported for independent command discovery. Antigravity, Cursor, Kimi and OpenCode inventory queries are unsupported.
const catalog = await runtime.commands(installation, { cwd: "/my/project" });
if (catalog.kind === "ok") {
for (const command of catalog.items) {
console.log(command.name, command.description, command.argumentHint);
}
}Command entries use native invocation names (Pi resolves duplicate extension
names itself), with optional source and aliases. Skills stay in skills();
hosts merge catalogs for a picker. Claude's native catalogs can overlap.
This is discovery, not execution or a live session's command state.
Command scope and limits.
Custom runtimes should use defineRuntime, which fills missing inventory
methods with explicit unsupported results. A manually constructed Runtime
must implement the four methods, including commands.
Runtime branding
runtime.brand contains { name, icon }. Built-in icons are self-contained SVG
data URIs, usable as an <img src> offline and serializable across IPC.
Reading branding needs no installation, login, or runtime process. Browser-only
consumers can read it without loading native SDKs:
import { runtimeBrands } from "@botiverse/oar/brands";
const { name, icon } = runtimeBrands.claude;The SVG files' attribution and licenses ship in assets/brands/NOTICE.md; Pi
uses the official pi.dev logo assets. Custom runtimes created with
defineRuntime may supply brand; otherwise it defaults to
{ name: runtime.id, icon: null }. Runtime branding identifies the provider
and is independent of an application's project avatars.
brand.icons?.light and brand.icons?.dark optionally override the default
for light and dark backgrounds; they need not be distinct or both present.
runtimeBrandIcon(brand, theme) returns the matching variant, falling back to
brand.icon (null for brands without artwork). Hosts choose the theme from
their own surface, not necessarily the operating system setting.
import { runtimeBrands, runtimeBrandIcon } from "@botiverse/oar/brands";
const src = runtimeBrandIcon(runtimeBrands.codex, "dark");