@sovara/runner
v0.4.6
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.
Maintainers
Readme
@sovara/runner
TypeScript runner for Sovara — wrap your script in a single function and Sovara records supported LLM, MCP, and framework tool calls, plus log lines, as structured run steps.
The runner is the TS counterpart to the Python sovara runner. It targets
the same Sovara server, produces the same step shapes, and is safe to mix
with Python runs in the same project.
Documentation: https://docs.sovara-labs.com
Requirements
- Node 18 or newer (built-in
fetch). - A reachable Sovara backend. For local use, open the Sovara desktop app. Remote agent hosts use a project-scoped agent token.
- A Sovara project name for
new SovaraClient({ projectName }).
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 OpenAI from "openai";
import { SovaraClient } from "@sovara/runner";
const openai_client = new OpenAI();
const sovara_client = new SovaraClient({ projectName: "My Project" });
await sovara_client.run("hello-world", async () => {
const response = await openai_client.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.
OpenAI Agents function tools created with tool() and MCP calls are also
captured automatically inside a run. Use trace only for important custom
operations that are not already captured.
How it works
Importing @sovara/runner patches the supported provider transports at module
load, including:
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 sovara_client.run(...), 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 lessons 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
new SovaraClient(options)
export type SovaraClientOptions = {
projectName: string; // required Sovara project name
url?: string; // exec server URL (default: http://127.0.0.1:5960)
agentToken?: string; // optional project-scoped agent token
fetch?: typeof globalThis.fetch; // optional caller-owned transport
};
export type SovaraRunOptions = {
clientRunId?: string; // optional project-scoped correlation id
captureLogs?: boolean; // default: true
lessonScope?: string | string[] | null; // optional lesson folder scope
};
export class SovaraClient {
run<T>(
name: string,
fn: () => Promise<T> | T,
options?: SovaraRunOptions,
): Promise<T>;
}The client owns project identity and connection configuration. run() 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.
- Uses the client's
projectNameandprocess.cwd()metadata for the run. - Registers the run, runs
fninside anAsyncLocalStoragescope, and deregisters infinally.
Nested top-level call (called from inside another sovara_client.run()):
The nested run is ignored with a warning and fn runs inside the existing
active scope. Use sovara_client.subrun(...) for child execution.
fn exceptions propagate untouched. The run is deregistered in either case.
Subruns
await sovara_client.run("pipeline", async () => {
const docs = await sovara_client.subrun("retrieve", async () => loadDocs());
await sovara_client.subrun("summarize", async () => summarize(docs));
});Every explicit sovara_client.subrun(...) becomes a child run 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 sovara_client.run(
"noisy",
async () => {
// stdout/stderr will not be tee'd to the server
},
{ captureLogs: false },
);Project and server
const sovara_client = new SovaraClient({ projectName: "Billing" });
await sovara_client.run("billing-job", async () => doWork());Pass projectName once when constructing the client. A remote agent host can
also configure its exec URL and project-scoped token there. The custom fetch
is optional:
const sovara_client = new SovaraClient({
projectName: "Billing",
url: "https://exec.example.com",
agentToken: process.env.SOVARA_AGENT_TOKEN,
fetch: customFetch,
});
await sovara_client.run("billing-job", async () => doWork());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 step in the Sovara
run steps.
import { SovaraClient, trace } from "@sovara/runner";
const sovara_client = new SovaraClient({ projectName: "My Project" });
const lookupCustomer = trace(
async function lookupCustomer(customerId: string) {
return { customerId, tier: "enterprise" };
},
{ meta: { system: "crm" } },
);
await sovara_client.run("support-agent", async () => {
const customer = await lookupCustomer("cust_123");
// Continue your agent flow with customer.
});trace records function arguments as the step input and the return value as
the step output. If the wrapped function throws, the error type and message
are recorded and the original exception is re-thrown. Outside an active
sovara_client.run(...) scope, the wrapper calls the function normally without
recording anything. Do not also wrap OpenAI Agents tool() functions or MCP
tools; Sovara captures those automatically.
export type TraceOptions = {
name?: string; // display name for the tool step, 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 as a run step. - 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 steps 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
sovara_client.run(...)scope,query()andstartup()are pass-throughs to the real SDK with zero overhead.
Example
import { SovaraClient } from "@sovara/runner";
import { query } from "@sovara/runner/claude";
const sovara_client = new SovaraClient({ projectName: "My Project" });
await sovara_client.run("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_EXEC_SERVER_PORT | Exec server port (used to compute the default URL) | 5960 |
| SOVARA_EXEC_SERVER_URL | Explicit exec server URL | http://127.0.0.1:5960 |
| SOVARA_AGENT_TOKEN | Project-scoped token for a remote/headless agent | unset |
| SOVARA_CLAUDE_PROXY_ACTIVE | Set automatically by @sovara/runner/claude to prevent nested proxy loops | unset |
Server URL
The TypeScript runner uses the local exec server by default. Pass url when
constructing the client if the caller manages a different exec-server connection:
const sovara_client = new SovaraClient({
projectName: "My Project",
url: "http://localhost:6000",
});
await sovara_client.run("demo", fn);Pass agentToken for remote project authentication. It falls back to
SOVARA_AGENT_TOKEN. Pass fetch only when a caller-owned transport is needed.
Error messages
The runner fails fast for misconfiguration. Common ones:
Sovara exec server is not reachable at <url>. Run sovara-exec-server start or use the Sovara CLI package that bundles it.Start the exec server or passurlwhen the caller owns exec-server setup.projectName is required.PassprojectNametonew SovaraClient(...).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.
