@sovara/runner
v0.3.3
Published
TypeScript runner for Sovara — wrap a script in a single function to record every LLM call, tool invocation, and log line into a structured run graph.
Downloads
476
Readme
@sovara/runner
TypeScript runner for Sovara — wrap your script in a single function and Sovara records every LLM call, tool invocation, and log line into a structured run graph.
The runner is the TS counterpart to the Python sovara runner. It targets
the same Sovara server, produces the same node shapes, and is safe to mix
with Python runs in the same project.
Requirements
- Node 18 or newer (built-in
fetch). - A reachable Sovara backend. For local use, open the Sovara desktop app;
for enterprise use, point
SOVARA_SERVER_URLat the remote endpoint. - A Sovara user and project, configured once with
sovara init.
Install
npm install @sovara/runner
# or
pnpm add @sovara/runner
# or
yarn add @sovara/runnerIf you use the Claude Agent SDK, install it as a peer dependency:
npm install @anthropic-ai/claude-agent-sdkQuick start
import { withSovaraRun } from "@sovara/runner";
import OpenAI from "openai";
const openai = new OpenAI();
await withSovaraRun("hello-world", async () => {
const response = await openai.responses.create({
model: "gpt-5",
input: "Say hello.",
});
console.log(response.output_text);
});That's the full integration. The call to OpenAI is captured automatically;
console.log output is captured as run logs; the run shows up in the
Sovara UI.
How it works
Importing @sovara/runner patches two transports at module load:
globalThis.fetch— wraps everyfetchcall.node:httpandnode:https— wrapshttp.request/http.getand their HTTPS counterparts. This coversaxiosand any library that uses Node's HTTP modules directly.
Inside withSovaraRun(...), calls to whitelisted endpoints are observed:
the runner builds a request envelope, posts it to the Sovara server's
/internal/runner/llm/prepare endpoint (which optionally applies
runtime preparation such as priors injection), executes the prepared
request on the wire, and records the response or exception. Outside the
run scope, both patches are no-ops.
Endpoints captured by default
| Provider | Path pattern |
| ---------------- | ----------------------------------------------------------------------------- |
| Anthropic | /v1/messages |
| OpenAI Responses | /v1/responses |
| OpenAI Chat | /v1/chat/completions |
| AWS Bedrock | bedrock-runtime.*.amazonaws.com/model/.../{converse,invoke} |
| Google Gemini | models/...:generateContent, :streamGenerateContent |
| Ollama | /api/chat, /api/generate, /api/embed, /api/embeddings |
| Tooling | Serper, Brave Search, Jina Reader, BrightData, Patronus, Contextual, Parallel |
Streaming responses (SSE, NDJSON, JSONL, chunked text) are tee'd: the user gets the bytes unchanged and the runner finalizes after the stream completes.
API
withSovaraRun(name | options, fn)
export type WithSovaraRunOptions = {
name: string; // required run name
url?: string; // server URL (default: SOVARA_SERVER_URL or http://127.0.0.1:5959)
captureLogs?: boolean; // default: true
user?: { fullName?: string; email?: string }; // overrides only
project?: { name?: string; description?: string };
};
export function withSovaraRun<T>(
input: string | WithSovaraRunOptions,
fn: () => Promise<T> | T,
): Promise<T>;The string form is shorthand for { name: input }. The runner returns
whatever fn returns.
Top-level call (no parent run on the stack):
- Health-checks the configured server and fails fast if it is unreachable.
- Resolves the active user. Fails hard with a
sovara inithint if user setup is missing. - Resolves the project for
process.cwd(). Fails hard with asovara inithint if the cwd isn't part of a known Sovara project. - Registers the run, opens an SSE shutdown listener, runs
fninside anAsyncLocalStoragescope, and deregisters infinally.
Nested call (called from inside another withSovaraRun):
The nested call is registered as a subrun via /runner/subrun, inheriting
the parent's client and url settings. It deregisters its own subrun
when fn returns.
fn exceptions propagate untouched. The run is deregistered in either
case.
Subruns
await withSovaraRun("pipeline", async () => {
const docs = await withSovaraRun("retrieve", async () => loadDocs());
await withSovaraRun("summarize", async () => summarize(docs));
});Every nested withSovaraRun becomes a subrun under the active scope.
Logs
Standard console.log, console.error, and any direct writes to
process.stdout / process.stderr are captured into the run log
buffer and flushed to the server every 250ms.
Disable with captureLogs: false:
await withSovaraRun({ name: "noisy", captureLogs: false }, async () => {
// stdout/stderr will not be tee'd to the server
});User and project overrides
await withSovaraRun(
{
name: "billing-job",
user: { fullName: "CI Bot", email: "[email protected]" },
project: { name: "Billing", description: "Nightly invoice run" },
},
async () => doWork(),
);Overrides are only applied if user/project are present. They don't bypass
the sovara init requirement — they just refine the values.
trace(fn, options?)
Use trace when an important custom function or tool does not make an LLM
HTTP call on its own, but should still appear as a tool node in the Sovara
run graph.
import { trace, withSovaraRun } from "@sovara/runner";
const lookupCustomer = trace(
async function lookupCustomer(customerId: string) {
return { customerId, tier: "enterprise" };
},
{ meta: { system: "crm" } },
);
await withSovaraRun("support-agent", async () => {
const customer = await lookupCustomer("cust_123");
// Continue your agent flow with customer.
});trace records function arguments as the node input and the return value as
the node output. If the wrapped function throws, the error type and message
are recorded and the original exception is re-thrown. Outside an active
withSovaraRun(...) scope, the wrapper calls the function normally without
recording anything.
export type TraceOptions = {
name?: string; // display name for the tool node, default: function/method name
meta?: Record<string, unknown>; // optional metadata stored with input/output
};The same helper can be used as a standard Stage 3 method decorator when your TypeScript configuration supports decorators:
class WeatherService {
@trace({ name: "weather_lookup" })
async lookup(city: string) {
return { city, forecast: "sunny" };
}
}Claude Agent SDK integration
ESM module namespaces in Node are immutable, so the runner cannot
monkey-patch @anthropic-ai/claude-agent-sdk from the outside. Instead,
@sovara/runner ships a drop-in wrapper at @sovara/runner/claude:
// before
import { query, startup } from "@anthropic-ai/claude-agent-sdk";
// after
import { query, startup } from "@sovara/runner/claude";That single import change is the entire integration. The wrapper:
- Routes the spawned Claude CLI subprocess through Sovara's local Claude
proxy by setting
ANTHROPIC_BASE_URLin the subprocess env, so every LLM request the SDK makes shows up in your run graph. - Registers a unique proxy client with the server before each
query()/startup()so concurrent SDK calls don't collide. - Sets
CLAUDE_CODE_ENTRYPOINT=sdk-tsandSOVARA_CLAUDE_PROXY_ACTIVE=1to mark the subprocess as running under Sovara. - Wraps the returned
AsyncIterableso each yielded message is parsed fortool_use/tool_resultblocks and recorded as nodes on the run. - Throws if
options.transport(custom transport) is supplied — this matches the Python runner's behavior. - Throws if
options.env.ANTHROPIC_BASE_URLalready points at the Sovara proxy, to prevent infinite proxy loops. - Outside a
withSovaraRunscope,query()andstartup()are pass-throughs to the real SDK with zero overhead.
Example
import { withSovaraRun } from "@sovara/runner";
import { query } from "@sovara/runner/claude";
await withSovaraRun("agent-task", async () => {
for await (const message of query({
prompt: "List the largest files in the current directory.",
})) {
if (message.type === "assistant") {
// tool_use blocks here are also recorded as Sovara nodes.
}
}
});startup()
import { startup } from "@sovara/runner/claude";
const handle = await startup({ options: {} });
for await (const message of handle.query("What did I install last?")) {
// ...
}startup() returns a warm handle whose query() is also instrumented.
Configuration
Environment variables
| Variable | Purpose | Default |
| ---------------------------- | -------------------------------------------------------------------------- | ----------------------- |
| SOVARA_SERVER_URL | Override the Sovara server URL | http://127.0.0.1:5959 |
| HOST | Server host (used to compute the default URL) | 127.0.0.1 |
| PYTHON_PORT | Server port (used to compute the default URL) | 5959 |
| SOVARA_CLAUDE_PROXY_ACTIVE | Set automatically by @sovara/runner/claude to prevent nested proxy loops | unset |
Server URL
Either pass url per call:
await withSovaraRun({ name: "demo", url: "http://localhost:6000" }, fn);Or set SOVARA_SERVER_URL once for the process.
Error messages
The runner fails fast for misconfiguration. Common ones:
Sovara backend is not reachable at <url>. Open the Sovara desktop app or configure SOVARA_SERVER_URL to a reachable enterprise Sovara backend endpoint.Open the desktop app for local runs, or pointSOVARA_SERVER_URLat the enterprise backend.Sovara user setup is missing. Run `sovara init --user-name <full-name> --user-email <email>` and retry.Run the suggestedsovara initcommand.Sovara project setup is missing for <cwd>. Run `sovara init --project-root "<cwd>" --project-name <project-name>` and retry.Run the suggestedsovara initcommand.Custom Claude Agent SDK transports are not supported yet under sovara.Removeoptions.transportfrom yourquery()/startup()call.Claude Agent SDK ANTHROPIC_BASE_URL points at the Sovara Claude proxy itself. Nested Claude proxy configuration is not supported.Don't manually setANTHROPIC_BASE_URLto the Sovara proxy URL — the wrapper sets it for you.
Limitations
- Direct
import { request, fetch } from "undici"calls are not intercepted (the runner patchesglobalThis.fetchandnode:http(s), which covers all common LLM clients today). Open an issue if you encounter an SDK that bypasses both. - The runner is Node-only. Browser, edge runtimes, and bundled
environments without Node's
node:http/async_hooksare out of scope. - If you import
@anthropic-ai/claude-agent-sdkdirectly anywhere in your project, those call sites are not instrumented. Use@sovara/runner/claudeeverywhere you want Sovara observability.
Development
npm install
npm test # vitest run, fully hermetic, no real network or server
npm run typecheck
npm run build # emit dist/ via tsc -p tsconfig.build.jsonReleasing
Publishing is automated via GitHub Actions using npm trusted publishing (OIDC).
There is no NPM_TOKEN secret — the runner-ts-release.yaml workflow
authenticates to npm via OIDC.
To cut a release:
- Bump
versioninrunner_ts/package.jsonon a release PR. Land it onmain. - From
main, create and push a tag matching the new version:git tag runner-ts-v0.2.0 git push origin runner-ts-v0.2.0 - The
Runner (TypeScript) — Releaseworkflow verifies the tag matchespackage.json, runs typecheck + tests + build, then publishes withnpm publish --access public.
The tag must match the package version exactly; mismatches fail the workflow.
