Caveman
CLI & SDKsTypeScript SDK

TypeScript SDK

A single-file TypeScript client for the Caveman gateway. It traces workflows, searches tool catalogs, delegates compression, proxies provider calls, and builds OTLP JSON without runtime dependencies.

The workspace package is @caveman-cloud/sdk (ES module, no runtime dependencies). It wraps the /sdk/v1/* gateway contract. It is not a compressor, tokenizer, or ranker; those decisions stay in the gateway and engine.

It mirrors the Python SDK field-for-field against the same wire contract. The two are kept in lockstep by a shared parity suite — a field that exists in one and not the other fails CI.

Install

There is no published @caveman/sdk on npm yet (the name is reserved for launch). Today you build from source in the workspace.

terminal
# from the repo root (pnpm workspace)
pnpm --filter @caveman-cloud/sdk build
# build, then run the runtime tests against dist/
pnpm --filter @caveman-cloud/sdk build && pnpm --filter @caveman-cloud/sdk test:node

The build emits an ES module under dist/. Import the Cave client and the types you need:

typescript
import { Cave } from "@caveman-cloud/sdk";
import type { CompressResult, ToolSearchResult } from "@caveman-cloud/sdk";

Workspace name vs. published name

The package is @caveman-cloud/sdk in the workspace; @caveman/sdk is the intended published name and is not live (it 404s as of this writing). Build from source until the npm redirect lands.

The client

new Cave(options) requires three fields — an API key, the gateway base URL, and an agent slug. The agent slug labels every span the SDK records.

typescript
import { Cave } from "@caveman-cloud/sdk";

const cave = new Cave({
apiKey: process.env.CAVE_API_KEY!,
baseURL: "http://127.0.0.1:8787",
agent: "my-service",
defaultWorkflow: "support-triage", // optional; defaults to "unlabeled-workflow"
});

The constructor throws if apiKey, baseURL, or agent is missing. retention ("metadata" | "zdr" | "configured") and controlURL (the control-plane host the jobs client targets) are optional.

Tracing

cave.trace(opts, fn) wraps a unit of work. Inside the callback you get a CaveTrace whose tool(), model, artifacts, and context helpers attribute everything to the trace's workflow. Tool spans are POSTed to /sdk/v1/events; telemetry failures are swallowed so they never break your agent.

typescript
await cave.trace({ workflow: "refund-flow", tags: { tier: "pro" } }, async (trace) => {
const result = await trace.tool("lookupOrder", { readOnly: true, idempotent: true }, async () => {
return db.orders.find(orderId);
});

const reply = await trace.model.openai.responses.create({
model: "gpt-4.1",
input: `Summarize order ${orderId}`,
});

return reply;
});

Checkpoints

trace.context.checkpoint(messages, options) POSTs a conversation to /sdk/v1/checkpoints; the gateway persists it and returns a reversible source_ref. trace.context.expand(sourceRef) is the other half — it GETs the stored { source_ref, version, messages, checkpoint } back. A checkpoint that cannot be expanded is a bug.

typescript
const cp = await trace.context.checkpoint(messages, { reason: "pre-summarization" });
// ... later, or in a peer step ...
const restored = await trace.context.expand(cp.source_ref as string);

Artifacts

trace.artifacts.page(value, options) pages a large payload out to /sdk/v1/artifacts and returns a compact [cave-artifact …] stub the model can expand on demand. strategy: "verbatim" bypasses storage and returns the value unchanged.

typescript
const stub = await trace.artifacts.page(bigSearchResult, {
source: "vector-search",
contentType: "application/json",
strategy: "json-index",
});

cave.tools({ catalog, strategy }) returns a handle with { initial, strategy, search() }. strategy: "all" puts the whole catalog in initial; strategy: "deferred" puts only the alwaysLoad tools plus the first initialToolCount (default 8). Either way, search() calls the gateway with the full catalog.

typescript
const tools = cave.tools({ catalog: myTools, strategy: "deferred" });
// send tools.initial on the first turn

const found: ToolSearchResult = await tools.search("refund a charge", {
maxTools: 5,
ranker: "bm25",
});
console.log(found.tools, found.savedTokens, found.reductionPct);

search() is async (a breaking change from 1.0, which was synchronous) — you must await it. The ranker ("bm25" | "embeddings") is passed through to the gateway verbatim; the gateway honors "embeddings" only when it has an embedding provider wired. The SDK never computes similarity itself. cave.toolSearch(catalog, query, opts?) is the flat variant for when you manage the catalog outside a handle.

Compression

cave.compress(payload, opts?) POSTs to /sdk/v1/compress and returns the Engine's report. The SDK delegates — it never reimplements a compressor, because that would fork behavior across surfaces.

typescript
const result: CompressResult = await cave.compress(bigJson, { contentType: "json" });
if (result.recoveryHandle) {
// dropped detail is recoverable byte-exact via the handle
}
console.log(result.ratio, result.basis); // e.g. 0.93, "inferred"

Honesty rule

compress() is byte-safe. On any transport or parse problem it passes the original payload through unchanged: ratio is 0, there is no recoveryHandle, and basis is always "inferred". The SDK never rewrites bytes itself and never reports a saving the engine did not hand back. Because it mirrors the Python SDK field-for-field, divergence between them fails CI.

Provider clients

cave.openai(), cave.anthropic(), cave.gemini(), and cave.vertex() return thin clients proxied through the gateway. Each carries a .raw fetch escape hatch for native provider paths the typed helpers don't cover. cave.bedrock({ region }) returns a local descriptor (sdkOnly: true) and makes no HTTP call.

typescript
const openai = cave.openai({ upstreamKey: process.env.OPENAI_API_KEY });
const out = await openai.responses.create({ model: "gpt-4.1", input: "hello" });

// vertex upstreamKey is a Google OAuth2 access token
const vertex = cave.vertex({ upstreamKey: await gcloudAccessToken() });
// native Vertex path via the escape hatch
const native = await vertex.raw(
"/vertex/v1/projects/p/locations/us-central1/publishers/google/models/gemini-1.5-pro:generateContent",
{ method: "POST", body: JSON.stringify(body) },
);

You can attach a latencyClass hint ("interactive" | "background" | "offline") to a responses.create call; anything other than "interactive" sets the x-cave-async header so the gateway can defer it.

The OTel exporter

cave.exporter({ serviceName? }) returns an OTelExporter bound to this client's gateway config. recordSpan() maps GenAI fields (provider, model, token counts, costUsd) to gen_ai.* semantic-convention attributes; export() POSTs the buffered OTLP/JSON batch to /otlp/v1/traces and clears the buffer. No external OpenTelemetry wiring, no runtime deps — the payload is built by hand.

typescript
const otel = cave.exporter({ serviceName: "billing-agent" });
const root = otel.recordSpan("chat.completion", {
provider: "openai",
model: "gpt-4.1",
inputTokens: 1200,
outputTokens: 340,
status: "ok",
});
otel.recordSpan("tool.call", { toolName: "lookupOrder", parentSpanId: root.spanId });
await otel.export(); // → { ok, spans_accepted, spans_total, ... }

The retry-loop breaker

cave.retryLoopBreaker(threshold = 3) returns a RetryLoopBreaker that interrupts a stuck agent re-issuing the same tool call. Call record(name, args) before each invocation (or wrap it with guard()); once the identical (name, args) signature repeats past the threshold it throws RetryLoopError. Any different call resets the streak.

typescript
const breaker = cave.retryLoopBreaker(3);
try {
await breaker.guard("search", { q: "refund" }, () => runSearch("refund"));
} catch (e) {
if (e instanceof RetryLoopError) {
// the agent was looping — break out and re-plan
}
}

Async jobs

cave.jobs targets /api/v1/jobs on options.controlURL and falls back to baseURL. submit() returns { id, state }, status() re-reads it, wait() polls to a terminal state, submitAndWait() does both, and cancel() requests cancellation. This is for gateway deployments that expose that public API path.

typescript
const job = await cave.jobs.submitAndWait(
{ kind: "report.export", project_id: projectId },
{ latencyClass: "background", timeoutMs: 120_000 },
);
console.log(job.state); // "completed" | "failed" | "cancelled"

See also