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)
}| Option | Default | Description |
|---|---|---|
configPath | .usertrust/usertrust.config.json | Path 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. |
proxy | undefined | Not implemented. Setting this option throws. Use a local TigerBeetle or dryRun for enforcement. |
key | undefined | Not implemented. API key for the removed proxy. Unused; proxy throws first. |
budget | Config value, else 50_000 | Total 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. |
pii | Config value or "warn" | PII enforcement: "redact", "warn", "block" or "off". Overrides the config file value. Any other value is refused by name. |
dryRun | false | Skip TigerBeetle entirely. Also enabled by USERTRUST_DRY_RUN=true env var. Useful for CI and testing. |
vaultBase | process.cwd() | Base directory containing the .usertrust/ vault. |
parentUserId | Config value, else unset | This 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. |
scope | Config value, else unset | Operator-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. |
endpoint | undefined | Explicit 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.
| Provider | Detection Shape | Intercepted Method |
|---|---|---|
| Anthropic | client.messages.create exists | messages.create() |
| OpenAI | client.chat.completions.create exists | chat.completions.create() |
client.models.generateContent exists | models.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
PolicyDeniedErrorwhosereasonstartsungoverned_method: <path>, after apolicy_deniedaudit event (the path asactionName). Underaudit.failClosed, a client whose audit writer is already degraded rejects withAuditDegradedErrorinstead, 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: Anthropicbeta.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 inpackages/core/src/default-deny.ts. - Opt-out.
allowUngoverned: ["embeddings.create"]runs a listed method ungoverned and audits each call asungoverned_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 aTypeErrorwhen the client is created: it would run without theungoverned_callrecord the option promises. - Raw clients. A resource's
_clientis the SDK's raw client, so every call through it is refused, and so are the client's raw HTTP members (post,get,request, …).allowUngovernedcannot list them. withOptions()returns a client governed by the same controller: same budget, audit chain and ledger. AwithOptions()clone shares its parent's controller:destroy()on either ends both, andgovernAction()on a clone runs under the shared controller, including on a clone whose transport was refused. AwithOptions()that changes where or how calls are sent is refused: every call through the new client rejects. Onlytimeout,maxRetries,defaultHeaders,defaultQuery,logLevel,logger,dangerouslyAllowBrowserand the API key and account options keep it governed;baseURL,fetch,fetchOptions, Anthropicmiddleware,credentials,configandprofile, OpenAIproviderandworkloadIdentity, 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 atgovernAction(); every*Internalmethod (generateContentInternalsends the request behindgenerateContent);authTokens.create, which mints an ephemeral key for Live sessions charged to your project;files.uploadandfiles.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.listand the like), and the POSTs named with a reason:models.update(metadata only),models.countTokens,models.computeTokens,batches.cancel,tunings.cancel,interactions.cancel, andoperations.get/getVideosOperation(polls). - Raw transport, refused by name:
apiClienton the client and on every namespace,getNextGenClient()and its cached_nextGenClient, a next-gen resource's_client, andlive.auth/live.webSocketFactory(and the same onlive.music).allowUngovernedcannot list a path through them, norchats.create(theChatit returns drives the SDK's unwrappedModels, so itssendMessagecalls would go unaudited),live.connectorlive.music.connect(the session they return sends billable frames over its open socket), orprocessAfcStream(one call runs a loop of generations), nor a governed method under another path (chats.modelsModule.generateContent/generateContentStream: call it onmodels; the*Internalmethods send one request each and can be listed).authTokens.createcan be listed: a key it mints and you use outside governance is out of scope, like readingapiKey. A method is decided by the path it is reached at:chats.modelsModule.generateContent, the SDK's unwrappedModelsunder another name, is refused. files.uploadis 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 togenerateContent, list it,trust(client, { allowUngoverned: ["files.upload"] }), and each upload runs on the raw SDK namespace, audited asungoverned_calland not redacted; or upload on the original (unwrapped) client.- Types. As on the other providers,
TrustedClientgives every refused method its SDK signature, so a method listed inallowUngoverned(saymodels.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
httpOptionsreads through as it is. Writes through the governed client throw aTypeError.
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, besideresponse, is provisional. The stream has not run yet, so it is priced at the estimate and hassettled: false.response.receiptis a promise of the settled receipt. It resolves when the stream ends, or when you stop reading early (abreak), 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:
usagefield on the final chunk.chat.completionsonly sends it when the request carriesstream_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 terminalresponse.completedevent with no opt-in. - Google:
usageMetadatafield, 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();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();