usertrust
API Reference

trust()

The core API — wraps any LLM client with governed spend, audit, and policy.

trust() is the single entry point for the usertrust SDK. It takes any supported LLM client, wraps it in a JS Proxy, and returns a governed client where every governed call — the provider methods listed under Provider detection — becomes an audited, budget-enforced financial transaction. Every other SDK method on the client is either refused or, if it only reads or deletes, runs unmetered; that rule is in the same section.

Signature

async function trust<T>(client: T, opts?: TrustOpts): Promise<TrustedClient<T>>

trust() is async because it initializes the vault, loads config, connects to TigerBeetle (unless dry-run), and sets up the audit chain before returning the governed client. If creating the ledger engine fails (a rejected connection or a failed account seed) it throws LedgerUnavailableError (failure mode 15.4) and never wraps the client. It first opens a plain TCP connection to the configured addresses: when every one refuses (checked within 1 s for a loopback address, 3 s for a remote one), trust() throws LedgerUnavailableError with No ledger is running at <address>. Fix: ... (npx usertrust tb start for the default ledger at 127.0.0.1:3001; for any other address or cluster the fix names the address and cluster to start). When something is listening but no usertrust ledger answers, the first request is bounded by tigerbeetle.connectTimeoutMs (default 5000) and then throws an error that says so. If the cluster evicts the client, the error says the evicted client cannot be released and the process must exit on its own. Use dryRun where no replica is running.

TrustOpts

interface TrustOpts {
  configPath?: string;    // Path to usertrust.config.json
  proxy?: string;         // Deprecated — not implemented; setting this throws
  key?: string;           // Deprecated — proxy API key; unused because proxy throws
  budget?: number;        // Token budget override (in usertokens)
  tier?: string;          // Overrides the config file's tier ("free" | "mini" | "pro" | "mega" | "ultra")
  pii?: "redact" | "warn" | "block" | "off"; // PII enforcement override
  parentUserId?: string;  // Ledger identity for cost-center envelopes; overrides config
  scope?: string[];       // Policy scopes for scopePatterns rules; overrides config
  dryRun?: boolean;       // Skip TigerBeetle — audit chain + policy still run
  vaultBase?: string;     // Vault directory override (default: process.cwd())
  endpoint?: Partial<EndpointInfo>; // Force local | cloud classification (wins over config + loopback autodetect)
  allowUngoverned?: string[]; // SDK methods to run ungoverned (every provider)
}
OptionDefaultDescription
configPath.usertrust/usertrust.config.jsonPath to the config file. Used exactly as given — a relative path resolves against process.cwd(), not vaultBase; only the default location is built under vaultBase.
proxyundefinedNot implemented. Setting this option throws. Use a local TigerBeetle or dryRun for enforcement.
keyundefinedNot implemented. API key for the removed proxy. Unused; proxy throws first.
budgetConfig value, else 50_000Total usertoken budget for this client. Overrides the config file value. 50_000 applies only when there is no config file; a config file without budget (and no budget option) fails validation.
tier"mini"Overrides the config file's tier. It is the tier that policy rules matching on tier are evaluated at. One of free, mini, pro, mega, ultra; any other value is refused when the client is built.
piiConfig value or "warn"PII enforcement: "redact", "warn", "block" or "off". Overrides the config file value. Any other value is refused by name.
dryRunfalseSkip TigerBeetle entirely. Also enabled by USERTRUST_DRY_RUN=true env var. Useful for CI and testing.
vaultBaseprocess.cwd()Base directory containing the .usertrust/ vault.
parentUserIdConfig value, else unsetThis controller's ledger identity — the parent half of every (parentUserId, costCenter) envelope account. Validated by the same rule as the config field; a malformed value throws at trust(). Trusted-operator input only.
scopeConfig value, else unsetOperator-declared policy scopes that scopePatterns rules match against. Up to 32 labels of [a-zA-Z0-9._@:+/-], 1–128 chars each. A request-body scope is stripped and ignored.
endpointundefinedExplicit Partial<EndpointInfo> override for endpoint classification — { class: "local" | "cloud", runtime?, baseURL? }. Applies only when class is set; runtime defaults to "unknown". Wins over config.endpoints matchers and loopback autodetect. Trusted-operator input only.
allowUngoverned[]trust() only: createGovernor() refuses it by name. SDK method paths to run ungoverned, as the refusal names them ("embeddings.create", "files.upload"). A listed method runs on the raw SDK with no hold, no PII scan and no cost, and each call appends an ungoverned_call audit event ({ method, provider }). Validated when trust() is called: an array of non-empty paths, none free, none governed, none through _client or a raw HTTP member such as post, and on a Google client none through apiClient, _nextGenClient, getNextGenClient, _interactions, _webhooks or the Live transports, and not chats.create, live.connect, live.music.connect or processAfcStream, whose one call hands out or drives further billable calls. authTokens.create can be listed: a key it mints and you use outside governance is out of scope, like reading apiKey. Without audit.failClosed the method returns what the SDK returns. Under audit.failClosed the append is awaited before the SDK is called, a failed append refuses the call, and the method always returns a Promise: a method that is synchronous in the SDK (chat.completions.stream) returns a Promise of its value, and beta.messages.toolRunner, whose runner is thenable, returns a Promise of the runner's FINAL result (promise resolution adopts the thenable and runs it to completion), not the runner object.

Every other config setting (circuitBreaker, patterns, audit, …) is read from the config file only. trust() refuses an option it does not declare: it throws a TypeError that names each unknown key, before anything is read or created, instead of ignoring it.

TESTING ONLY The internal _engine and _audit options exist for testing only. They inject mock subsystems and are not part of the public API. They are honoured only when USERTRUST_TEST=1 or NODE_ENV=test; anywhere else they are silently ignored.

TrustedClient<T>

type TrustedClient<T> = GovernedShape<T> & {
  destroy(): Promise<void>;
  governAction<R>(action: ActionDescriptor, execute: () => Promise<R>): Promise<GovernedActionResult<R>>;
};

The returned client has the shape of the original client with the governed methods retyped (that is GovernedShape<T>): the governed create methods return { response, receipt }, messages.stream() becomes async and carries a receipt promise, and messages.parse() returns the parsed message with a settled receipt attached. It also gains destroy() and governAction() for non-LLM actions. You call the same methods you already use (messages.create, chat.completions.create, etc.) and get governed responses back. Any other SDK method keeps its SDK signature in the type, but is refused at runtime unless it is free or listed in allowUngoverned (see Provider detection).

CALL DESTROY() Always call destroy() when you are done with the client. Without it, the process will hang due to open TigerBeetle connections and file locks.

Return Value

Every governed create() and generateContent() call returns { response, receipt } instead of the raw provider response:

const { response, receipt } = await client.messages.create({
  model: "claude-fable-5",
  max_tokens: 1024,
  messages: [{ role: "user", content: "Hello" }],
});

The response is the original, unmodified provider response. The receipt is a TrustReceipt with governance metadata. For a streaming call the envelope's receipt is only an estimate — settled: false, cost equal to the pre-call estimate, no usage — and the settled receipt arrives on response.receipt once the stream has been consumed.

TrustReceipt

interface TrustReceipt {
  transferId: string;         // Universal join key across ledger, audit, receipts
  cost: number;               // Actual cost in usertokens
  budgetRemaining: number;    // Remaining budget after this call
  auditHash: string;          // SHA-256 hash of the audit event, or the sentinel "AUDIT_DEGRADED"
  chainPath: string;          // Vault-relative audit chain directory — currently ".usertrust/audit"
  receiptUrl: string | null;  // Always null today — only proxy mode set it, and proxy mode throws
  settled: boolean;           // true if POST succeeded; false on failure mode 15.1 and on the estimated receipt a streaming call returns up front
  model: string;              // Model used for the call
  provider: string;           // "anthropic" | "openai" | "google"
  timestamp: string;          // ISO 8601 timestamp
  auditDegraded?: boolean;    // true if a chain append for the call failed (mode 15.3; a failed rotated-receipt write, with `audit.rotation` on, is ignored and neither sets this nor degrades the writer, even under `failClosed`); its order relative to the settle varies by path and record (a capped non-stream settle posts before its shortfall record is appended); a failed `injection_detected` append in warn mode is swallowed without marking the call, so that receipt carries a normal hash and no flag even though the client is now degraded
  postedCost?: number;        // Ledger amount actually posted when capped at the hold (cost - postedCost = shortfall)
  usageSource?: "provider" | "estimated"; // Where cost came from
  usage?: ReceiptUsage;       // Four-tier token split; present when a complete provider report priced the call
  cacheWrite1h?: { tokens: number; ratePer1k: number }; // 1-hour share of usage.cacheWriteTokens; only when the call wrote 1-hour cache
  chunksDelivered?: number;   // Streaming calls only
  actionKind?: ActionKind;    // governAction() receipts only
  endpoint?: { class: "local" | "cloud"; runtime: LocalRuntime };
  meter?: { costBasis: "usd-proxy" | "nominal"; rateSource: RateSource; computeMs?: number };
  pricing?: { appliedRates: AppliedRates; tableVersion: string }; // Rates the cost was metered with
  budget?: { costCenter: string; remaining: number; fraction?: number }; // Only inside withCostCenter()
}

The transferId is the universal join key. Use it to correlate entries across the TigerBeetle ledger, the audit chain, and receipt files.

Provider Detection

trust() identifies the LLM SDK by structural shape (duck typing). It never imports provider SDKs directly.

ProviderDetection ShapeIntercepted Method
Anthropicclient.messages.create existsmessages.create()
OpenAIclient.chat.completions.create existschat.completions.create()
Googleclient.models.generateContent existsmodels.generateContent(), models.generateContentStream()

If the client does not match any known shape, trust() throws an error at initialization time.

Everything else on an Anthropic or OpenAI client is refused

The governed methods are messages.create, messages.stream and messages.parse (and the same on beta.messages) on Anthropic, and chat.completions.create and responses.create on OpenAI. Every other function-valued SDK method reached off the governed client, at any depth, is refused unless it is on the free list:

  • The call returns a promise that rejects with a PolicyDeniedError whose reason starts ungoverned_method: <path>, after a policy_denied audit event (the path as actionName). Under audit.failClosed, a client whose audit writer is already degraded rejects with AuditDegradedError instead, before the refusal is recorded (nothing is appended). The SDK is never called and nothing is held. A synchronous SDK helper (chat.completions.stream, beta.messages.toolRunner) returns the rejected promise too.
  • Free list, by rule. A method whose requests are all GET or DELETE (a retrieve, list, download, delete or poll) runs on the raw SDK resource, unaudited: a read cannot start billable work and a delete only reduces storage. A POST runs free only when it is named with a reason: token counts, moderations.create, cancels, pauses, metadata updates and a few others. A local method (webhook signature checks) runs free when named. Six GET or DELETE methods are refused by name because they can start work or lift a spend control: Anthropic beta.environments.work.poll, and OpenAI's spend-limit, spend-alert and model-permission deletes. The exact list, with each method's SDK source line and verb, is in packages/core/src/default-deny.ts.
  • Opt-out. allowUngoverned: ["embeddings.create"] runs a listed method ungoverned and audits each call as ungoverned_call, with no usage and no cost. An entry that names a free method (models.list) or a governed one (messages.create) is refused with a TypeError when the client is created: it would run without the ungoverned_call record the option promises.
  • Raw clients. A resource's _client is the SDK's raw client, so every call through it is refused, and so are the client's raw HTTP members (post, get, request, …). allowUngoverned cannot list them.
  • withOptions() returns a client governed by the same controller: same budget, audit chain and ledger. A withOptions() clone shares its parent's controller: destroy() on either ends both, and governAction() on a clone runs under the shared controller, including on a clone whose transport was refused. A withOptions() that changes where or how calls are sent is refused: every call through the new client rejects. Only timeout, maxRetries, defaultHeaders, defaultQuery, logLevel, logger, dangerouslyAllowBrowser and the API key and account options keep it governed; baseURL, fetch, fetchOptions, Anthropic middleware, credentials, config and profile, OpenAI provider and workloadIdentity, and any other key refuse it.

Symbols, non-function values (baseURL, maxRetries, …) and Object.prototype members pass untouched, so inspection and serialization behave as on the raw client. The TrustedClient type still gives a refused method its SDK signature.

On a Google client

A Google client (@google/genai 1.52.0 or later) is default-deny the same way: every method other than models.generateContent and models.generateContentStream is refused unless it is free or listed in allowUngoverned. A refused call rejects with a PolicyDeniedError naming the method (carrying the auditEventHash of the policy_denied event it records) before the SDK is called.

  • Refused: the billable methods (models.embedContent, generateImages, editImage, upscaleImage, generateVideos, recontextImage, segmentImage, chats.create, live.connect, live.music.connect, batches.create, batches.createEmbeddings, interactions.create, tunings.tune, caches.create, caches.update, fileSearchStores.uploadToFileSearchStore, fileSearchStores.importFile, fileSearchStores.create), whose error points at governAction(); every *Internal method (generateContentInternal sends the request behind generateContent); authTokens.create, which mints an ephemeral key for Live sessions charged to your project; files.upload and files.registerFiles; and the webhook configuration methods (webhooks.create, update, ping, rotateSigningSecret).
  • Free, by the same rule as above: the reads, lists and deletes (every GET or DELETE: models.list, caches.get, files.download, interactions.get, webhooks.list and the like), and the POSTs named with a reason: models.update (metadata only), models.countTokens, models.computeTokens, batches.cancel, tunings.cancel, interactions.cancel, and operations.get / getVideosOperation (polls).
  • Raw transport, refused by name: apiClient on the client and on every namespace, getNextGenClient() and its cached _nextGenClient, a next-gen resource's _client, and live.auth / live.webSocketFactory (and the same on live.music). allowUngoverned cannot list a path through them, nor chats.create (the Chat it returns drives the SDK's unwrapped Models, so its sendMessage calls would go unaudited), live.connect or live.music.connect (the session they return sends billable frames over its open socket), or processAfcStream (one call runs a loop of generations), nor a governed method under another path (chats.modelsModule.generateContent / generateContentStream: call it on models; the *Internal methods send one request each and can be listed). authTokens.create can be listed: a key it mints and you use outside governance is out of scope, like reading apiKey. A method is decided by the path it is reached at: chats.modelsModule.generateContent, the SDK's unwrapped Models under another name, is refused.
  • files.upload is unredacted. An upload sends the file's bytes with no PII scan, so it is refused even though it starts no model work. To upload a file and then pass it to generateContent, list it, trust(client, { allowUngoverned: ["files.upload"] }), and each upload runs on the raw SDK namespace, audited as ungoverned_call and not redacted; or upload on the original (unwrapped) client.
  • Types. As on the other providers, TrustedClient gives every refused method its SDK signature, so a method listed in allowUngoverned (say models.embedContent) types as the SDK's own result.
  • Duck-typed clients. Any object reached off a Google-shaped client that has a function member (or an accessor) is guarded like an SDK namespace, so a hand-built client's methods are refused unless they are free or listed. Plain data such as httpOptions reads through as it is. Writes through the governed client throw a TypeError.

With automatic function calling (a callable tool in config.tools, AFC not disabled), generateContent is metered per round: usertrust runs the loop itself, each round its own governed call, and the envelope carries receipts, one per round. generateContentStream with automatic function calling is refused the same way for now, because a streamed loop is not yet metered round by round: use generateContent, or set config.automaticFunctionCalling.disable: true and run the tool calls yourself. Either method sends the SDK a snapshot of the checked request: accessors (getters) in config, config.tools and config.automaticFunctionCalling are read once and their values captured, and a Proxy there is refused.

Streaming

Streaming is transparent. When you pass stream: true to the provider, usertrust returns a GovernedStream that yields chunks identically to the native stream. A streaming call carries two receipts:

  • receipt, beside response, is provisional. The stream has not run yet, so it is priced at the estimate and has settled: false.
  • response.receipt is a promise of the settled receipt. It resolves when the stream ends, or when you stop reading early (a break), in which case the usage consumed so far is settled.

Google's models.generateContentStream() takes no stream flag and returns no envelope: like the SDK's own method it resolves to the stream itself, and the settled receipt is stream.receipt. Everything below applies to it the same way.

import { AuditDegradedError } from "usertrust";

const { response, receipt: provisional } = await client.messages.create({
  model: "claude-fable-5",
  max_tokens: 1024,
  messages: [{ role: "user", content: "Write a poem" }],
  stream: true,
});
// provisional.settled === false

let streamError: unknown;
try {
  for await (const chunk of response) {
    try {
      process.stdout.write(chunk.delta?.text ?? "");
    } catch (err) {
      // Your own code threw. Stopping early is not a stream failure: what was
      // consumed settles and is charged, as with any `break`.
      console.error(err);
      break;
    }
  }
} catch (err) {
  streamError = err; // the stream itself threw (or, under audit.failClosed, refused)
}

try {
  const receipt = await response.receipt; // settled: true
  console.log(`Cost: ${receipt.cost} UT`);
} catch (err) {
  if (err === streamError && !(err instanceof AuditDegradedError)) {
    // The stream failed mid-way: its hold was voided, nothing was charged.
  } else {
    // AuditDegradedError (audit.failClosed), or another settlement error:
    // settlement ran, so the work may be billed. Do not simply retry it.
  }
}

The billing claim is made in one place only: the catch around await response.receipt, which covers none of your code. The loop's body has its own try, so a throw there is never mistaken for a stream failure.

When a stream fails mid-way and its iteration throws (a cut connection, or a provider error event that the SDK throws, as Anthropic's and OpenAI's chat.completions streams do), response.receipt rejects with that same error object. The budget hold is released by a void request (best-effort: a void the ledger refuses leaves it reserved until it expires, up to 300 s), not charged, and the audit chain records a stream_partial_delivery event with the chunks delivered and the tokens counted before the failure. A throw from your own loop body is NOT a stream failure: the loop exits early, and the usage consumed so far settles and is charged, as with a break.

A stream you cancel with your own abort signal is an early stop, not a failure. When the signal you passed for that call (Google config.abortSignal; OpenAI's and Anthropic's request-options signal) is aborted, the SDK's iteration throws its abort error, and it still reaches your loop, but the usage delivered so far settles and is charged, exactly as on a break (at the estimate when no usage was reported), and response.receipt (or stream.receipt) resolves. It records no circuit-breaker failure. The decision reads your signal's state, never the error's name. A provider error with your signal not aborted, and a governance anomaly cutoff, still void.

An OpenAI Responses error event does not throw. The OpenAI SDK yields a responses.create({ stream: true }) error event (type: "error") as an ordinary chunk, so the stream ends and settles like any completed stream: at the estimate when no usage arrived. It is charged. Check each chunk's type to tell it apart. Whether such a stream should be charged is an open settlement question.

A rejected response.receipt does not always mean nothing was charged. It rejects with either the iteration's own error (the same object: the hold's void was requested; the void is best-effort, so a void the ledger refuses leaves the hold reserved until it expires, up to 300 s), or, under audit.failClosed, an AuditDegradedError: settlement already ran (the charge posted, or its POST failed and the outcome is ambiguous: it may still post), then the audit write failed. Either way the work may be billed, so retrying it can charge it twice. AuditDegradedError is exported from usertrust, so test err instanceof AuditDegradedError to tell the two apart. Any other settlement error also rejects .receipt alone, after settlement claimed the call, so treat it like the audit refusal: the work may be billed.

Under audit.failClosed, that refusal also ends the iteration of a messages.create, chat.completions.create, responses.create or Google models.generateContentStream stream: the loop's last step, or the break that ends it early, throws the AuditDegradedError. Settlement has already run, and nothing is voided. Only AuditDegradedError ends the iteration: any other settlement error rejects .receipt alone, and the loop ends cleanly. If your loop body throws, the loop throws YOUR error, not the audit refusal (JavaScript keeps the original throw), so await .receipt to see a refusal. A messages.stream() caller must await .receipt under failClosed: its iteration and finalMessage() do not carry the audit refusal.

Skipping await response.receipt never crashes the process: a rejected receipt is never left as an unhandled rejection. On messages.create, chat.completions.create, responses.create and models.generateContentStream streams, a stream failure reaches the iteration, and so does an audit refusal under failClosed when the loop ends normally or with a break (not when your loop body throws); any other settlement error reaches only .receipt. A messages.stream() caller under failClosed hears of an audit refusal only from .receipt.

Token counts are accumulated per-provider from stream chunks:

  • Anthropic: message_start (input tokens), message_delta (output tokens)
  • OpenAI: usage field on the final chunk. chat.completions only sends it when the request carries stream_options: { include_usage: true }; usertrust injects that for local endpoints only, so against the OpenAI cloud pass it yourself or the stream settles at the pre-call estimate (usageSource: "estimated"). Responses API streams carry usage on the terminal response.completed event with no opt-in.
  • Google: usageMetadata field, read from the latest chunk that carries it (each one is a running total, so nothing is summed)

Examples

Anthropic

import Anthropic from "@anthropic-ai/sdk";
import { trust } from "usertrust";

const client = await trust(new Anthropic(), {
  budget: 50_000,
  dryRun: true,
});

const { response, receipt } = await client.messages.create({
  model: "claude-fable-5",
  max_tokens: 1024,
  messages: [{ role: "user", content: "Explain double-entry accounting" }],
});

console.log(response.content[0].text);
console.log(`Cost: ${receipt.cost} UT, remaining: ${receipt.budgetRemaining}`);

await client.destroy();

OpenAI

import OpenAI from "openai";
import { trust } from "usertrust";

const client = await trust(new OpenAI(), { budget: 30_000 });

const { response, receipt } = await client.chat.completions.create({
  model: "gpt-5.6-sol",
  messages: [{ role: "user", content: "What is a Merkle tree?" }],
});

console.log(response.choices[0].message.content);
console.log(`Settled: ${receipt.settled}, hash: ${receipt.auditHash}`);

await client.destroy();

Google

import { GoogleGenAI } from "@google/genai";
import { trust } from "usertrust";

const client = await trust(new GoogleGenAI({ apiKey: "..." }), {
  budget: 20_000,
});

const { response, receipt } = await client.models.generateContent({
  model: "gemini-2.5-pro",
  contents: "Explain hash chains",
});

console.log(response.text);
console.log(`Transfer: ${receipt.transferId}`);

// Streaming: resolves to the stream itself, with the settled receipt on it
const stream = await client.models.generateContentStream({
  model: "gemini-2.5-pro",
  contents: "Explain hash chains",
});
for await (const chunk of stream) process.stdout.write(chunk.text ?? "");
const settled = await stream.receipt;
console.log(`Settled: ${settled.cost} UT`);

await client.destroy();

Streaming (Anthropic)

import Anthropic from "@anthropic-ai/sdk";
import { trust } from "usertrust";

const client = await trust(new Anthropic(), { budget: 50_000 });

const { response } = await client.messages.create({
  model: "claude-fable-5",
  max_tokens: 2048,
  messages: [{ role: "user", content: "Write a short story" }],
  stream: true,
});

try {
  for await (const event of response) {
    if (event.type === "content_block_delta") {
      process.stdout.write(event.delta.text);
    }
  }
  const receipt = await response.receipt;
  console.log(`\n\nCost: ${receipt.cost} UT`);
} catch (err) {
  // What this error cost, and how to tell, is under Streaming above: this
  // catch also covers your own code in the loop, whose throw is charged.
}

await client.destroy();