Financial governance for AI agents. Every governed LLM call becomes a tamper-evident, auditable transaction.
import { trust } from "usertrust";
import Anthropic from "@anthropic-ai/sdk";
// dryRun: true — skips TigerBeetle so you can try instantly.
// Audit chain and policy engine still run.
// You still need a provider API key — dryRun skips the ledger, not the provider call.
const client = await trust(new Anthropic(), { dryRun: true, budget: 50_000 });
const { response, receipt } = await client.messages.create({
model: "claude-fable-5",
max_tokens: 1024,
messages: [{ role: "user", content: "Analyze this contract" }],
});
console.log(receipt);
// REQUIRED — process hangs without this
await client.destroy();new Anthropic() reads your key from ANTHROPIC_API_KEY. usertrust init saves the key you give it in .usertrust/.env but does not export it, so a fresh shell has no key. Run the example with Node's own .env loader:
node --env-file=.usertrust/.env app.mjsor export ANTHROPIC_API_KEY=... yourself first.
That's it. One function wraps any supported LLM client. Every governed call is metered, policy-checked and recorded on the audit chain; a call whose record could not be written says so on its receipt (auditDegraded, see LIMITATIONS.md, section 5).
The receipt returned from every governed call:
npm install usertrust @anthropic-ai/sdk
npx usertrust initnpx usertrust init does not install the package into your project, so the npm install line is required. import { trust } from "usertrust" fails until it runs.
usertrust wraps your provider's own client and does not install it. Install the SDK for the provider you use beside it: @anthropic-ai/sdk for the example above, openai, or @google/genai for Google Gemini (npm install usertrust @google/genai). @anthropic-ai/sdk (0.116.0 or newer) and openai (7.4.0 or newer) are declared as optional peer dependencies. @google/genai is not declared at all: usertrust recognises a Google client by its shape (models.generateContent), so nothing in the install names that package, and you install it yourself.
init is interactive in a terminal. It asks for your provider API key (Anthropic sk-ant-, OpenAI sk- and Google AIza keys are detected from their prefix; any other provider you name yourself) and writes it to .usertrust/.env. A new or rewritten .env is always owner-only (mode 600): init --reconfigure writes a fresh owner-only file and renames it over the old one, whatever the old file's mode, and replaces a symlinked .env rather than writing through it. An Anthropic, OpenAI or Google key is first checked against that provider's models endpoint (unless you pass --skip-verify); a key that fails the check is still written, with a warning. A key for a provider you name yourself is not checked at all, even though init reports it as valid, so a wrong key there shows up only on the first real call.
Without a terminal (CI, scripts, a container build), run npx usertrust init --json: it creates the vault without prompting and without asking for a key.
This creates a .usertrust/ vault in your project root with default config, policies, and an empty audit chain.
The vault is owner-only on Linux and macOS: every directory usertrust creates in it is 0700 and every file 0600, and a .usertrust/ another user owns is refused. On Windows usertrust does not set or check permissions, because POSIX modes do not map to ACLs: the vault folder's inherited ACL governs who can read it. Keep the vault (and a usertrust-server's stateDir) under your own user profile, or give the folder an owner-only ACL yourself. See LIMITATIONS.md.
Both module systems work on Node ≥ 22.12:
import { trust } from "usertrust"; // ESM
const { trust } = require("usertrust"); // CJSThe package ships a single ESM build; require() loads it through Node's built-in require(esm) support, which landed unflagged in 22.12. There is no second CJS build, so a process gets exactly one copy of the module graph, and with it one shared audit writer per vault, whichever way you load it. Budget state is not shared that way: each trust() client builds its own budget mutex, in-flight hold accounting and holding wallet. On Node 22.0–22.11 require("usertrust") fails with ERR_REQUIRE_ESM; import works on any Node ≥ 22.
Works with the Anthropic (@anthropic-ai/sdk), OpenAI (openai) and Google Gemini (@google/genai) SDKs:
import { trust } from "usertrust";
import Anthropic from "@anthropic-ai/sdk";
import OpenAI from "openai";
import { GoogleGenAI } from "@google/genai";
// Anthropic
const anthropic = await trust(new Anthropic());
const { response, receipt } = await anthropic.messages.create({ ... });
// OpenAI
const openai = await trust(new OpenAI());
const { response, receipt } = await openai.chat.completions.create({ ... });
// Google Gemini: the client reads GOOGLE_API_KEY, the name `init` stores a Google key under
const google = await trust(new GoogleGenAI({}));
const { response, receipt } = await google.models.generateContent({ model: "gemini-2.5-flash", contents: "..." });
// With options (full mode — requires TigerBeetle)
const client = await trust(new Anthropic(), {
budget: 100_000,
});On a Google client models.generateContent and models.generateContentStream are governed. generateContentStream resolves to the stream itself, as the SDK's does, with the settled receipt on it: const stream = await google.models.generateContentStream({ ... }), then for await (const chunk of stream) and await stream.receipt. Every other Google method is refused unless it is free, as on the other providers (below): the billable ones (models.embedContent, the image and video methods, chats.create, live.connect, batches.create, interactions.create, tunings.tune, caches.create / update, the fileSearchStores uploads, imports and creates), every *Internal method, authTokens.create, files.upload, and the SDK's raw transport (apiClient, getNextGenClient(), a next-gen resource's _client, live.auth, live.webSocketFactory) reject with a PolicyDeniedError naming the method, and record a policy_denied audit event, before the SDK is called. files.upload sends the file's bytes with no PII scan: to upload a file and then pass it to generateContent, list it, allowUngoverned: ["files.upload"] (audited as ungoverned_call, and unredacted), or upload on the original client. 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. Reads, lists, deletes, models.countTokens and cancels run free (@google/genai 1.52.0 or later).
On every client, every SDK method usertrust does not govern is refused unless it cannot start work: embeddings.create, messages.batches.create, chat.completions.parse and the like return a promise that rejects with a PolicyDeniedError (ungoverned_method: <path>) and write a policy_denied audit event, and the SDK is never called. Methods that cannot start work run as before: reads, lists and deletes (every GET or DELETE request) and a few named POSTs such as messages.countTokens, moderations.create and cancels. To run a refused method unmetered, list it: trust(client, { allowUngoverned: ["embeddings.create"] }); each such call is audited as ungoverned_call. client.withOptions(...) returns a client governed by the same governor, unless it changes where or how calls are sent (baseURL, fetch, fetchOptions, …), which is refused.
Clients in one process share their vault's audit writer. Separate processes cannot write to one vault: give each process its own vault (vaultBase), or send their calls through one usertrust-server.
Every call returns { response, receipt }:
{
transferId: "tx_m4k7r2_a1b2c3",
cost: 142,
budgetRemaining: 49_858,
auditHash: "a3f8...",
chainPath: ".usertrust/audit",
receiptUrl: null,
settled: true,
model: "gpt-5.6-sol",
provider: "openai",
timestamp: "2026-03-16T12:00:00.000Z"
}- Settings live in the config file. Options such as
piiare read from the config filetrust()loads:.usertrust/usertrust.config.jsonby default, or the file you pass asconfigPath, which is read INSTEAD of the default (edit the one in use, or a setting such aspii: "block"stays off). See Config.piican also be passed totrust()itself, e.g.trust(client, { pii: "block" }), which overrides the file; any optiontrust()does not declare is refused by name rather than ignored. - A budget belongs to one
trust()client, seeded from the spend the vault has recorded.trust(client, { budget })gives that client its own budget, set when the client is created to the budget minus the spend already recorded in the vault (.usertrust/spend-ledger.json). Two clients created before either has settled a call each start with the full amount; a client created after another has settled starts with what is left; and a client that is already running does not see another client's later spend. To share one budget across callers, useusertrust-server. - With TigerBeetle, do not create one
trust()per agent. Each ledger-backed client holds its own connection to the ledger. In our testing, the 9th simultaneously open ledger-backed client in one process never finished connecting, and no error was raised; a TigerBeetle replica also holds at most 64 registered clients and evicts the least recently used. For more than a few agents, runusertrust-server(seepackages/server/README.md): agents call it over HTTP, and it keeps one governor and one ledger connection per tenant, shared by every agent of that tenant.
npx usertrust inspect=== Vault Report ===
Chain: 847 events · 12 segments
Budget: 38,420 / 50,000 UT remaining
Models: claude-fable-5 (412) · gpt-5.6-sol (289) · kimi-k3 (146)
Policy: 3 rules active · 0 violations
PII: 2 warnings · 0 blocks
Merkle: a3f8c1...d92b (root)
All commands support --json for machine-readable output.
usertrust init # Create .usertrust/ vault
usertrust inspect # Vault bank statement
usertrust health # Entropy diagnostics (6 signals, 0-100 score)
usertrust verify # Verify audit chain integrity and completeness (+ external anchors, --receipts)
usertrust anchor # External anchoring: init|now|status|doctor|export|export-bundle|rotate|resume
usertrust snapshot # Checkpoint/restore vault state
usertrust tb # TigerBeetle process management
usertrust completions # Shell completions (bash, zsh, fish)Every command supports --json for scripting and CI:
usertrust inspect --json | jq '.data.remaining'# Bash
usertrust completions bash > ~/.local/share/bash-completion/completions/usertrust
# Zsh
usertrust completions zsh > "${fpath[1]}/_usertrust"
# Fish
usertrust completions fish > ~/.config/fish/completions/usertrust.fishAll errors include fix suggestions and documentation links:
Ledger unavailable: connection refused
Hint: Start TigerBeetle 0.17.x (tigerbeetle format … then tigerbeetle start --addresses=127.0.0.1:3001) or use { dryRun: true } to skip the ledger.
Docs: https://usertrust.ai/docs/errors/ledger-unavailable
In 4.1.0, usertrust tb start only prints the manual steps, so the hint above refers to them. Start TigerBeetle as described in Running TigerBeetle, or use { dryRun: true }.
Create .usertrust/usertrust.config.json:
{
"budget": 50000,
"tier": "pro",
"pii": "block",
"circuitBreaker": { "failureThreshold": 5, "resetTimeout": 60000 },
"patterns": { "enabled": true },
"audit": { "rotation": "daily", "indexLimit": 10000 }
}budget, pii, parentUserId and scope can also be passed to trust() (and to createGovernor()), and override the file: trust(client, { pii: "block" }) blocks PII whatever the file says. Every other setting (circuitBreaker, patterns, audit, …) is read from the file only. Besides these, its own options (configPath, dryRun, vaultBase, endpoint, allowUngoverned) and tier (accepted, but currently not applied), any key passed to trust() is refused by name: it throws a TypeError such as trust(): unknown option "circuitBreaker" (set it in .usertrust/usertrust.config.json) instead of being ignored. So does a pii value the config does not allow. allowUngoverned is for trust() only: createGovernor() refuses it by name.
defineConfig is available as a TypeScript type-checking helper for validating config objects in your code. The actual config file must be usertrust.config.json (JSON format):
import { defineConfig } from "usertrust";
// Type-check your config object — useful for programmatic overrides
const config = defineConfig({
budget: 50_000,
tier: "pro",
pii: "block",
circuitBreaker: { failureThreshold: 5, resetTimeout: 60_000 },
patterns: { enabled: true },
audit: { rotation: "daily", indexLimit: 10_000 },
});Double-entry ledger — TigerBeetle-backed financial transactions with two-phase lifecycle (PENDING, POST, VOID). Not a counter.
SHA-256 hash-chained audit — Every event links to the previous event's hash. Tamper-evident by construction. Append-only JSONL.
Merkle proofs (RFC 6962) — Inclusion and consistency proofs for public verifiability. Any third party can verify a specific event existed in the chain.
External anchoring — Ed25519-signed chain-head checkpoints pushed to an append-only store the vault operator cannot rewrite (S3 Object Lock, protected git branch, SIEM). Even a fully re-hashed, internally-consistent rewrite of the events an anchor covers fails verification against the externally-held root; events appended after the last anchor are covered only by the next one. Ships a native SigV4 S3 sink (no AWS SDK), an experimental Rekor transparency-log witness that publishes digests only, anchor doctor to probe whether the writer identity can actually delete or overwrite in the store, and anchor export-bundle to hand an auditor the records and receipts in one file. See the anchoring guide.
Policy engine — 12 field operators (eq, gt, in, regex, etc.) with soft/hard enforcement. Block specific models, cap costs, require approvals.
PII detection — a pattern scan of each request's prompt text for email, phone, SSN, Luhn-validated credit card, IPv4 and common secret formats (API keys, cloud and GitHub tokens, JWTs, private keys). Block, redact or warn before the prompt leaves your process. It reads the prompt (messages, input, contents), not tool definitions or other request fields, and never the response; see what it does and does not cover.
Circuit breakers — Per-provider failure isolation. When a provider starts failing, the breaker opens and requests fail fast instead of cascading.
Pattern memory — Learns optimal model routing from historical prompt-cost-success data. Feeds routing decisions when connected to proxy.
AI agents operate with financial authority. Every LLM call costs money. Without governance:
- There is no audit trail when an agent spends $500 on a hallucinated loop
- There is no budget enforcement across multiple concurrent agents
- There is no way to prove what happened after the fact
- A race condition between two agents can double-spend the same budget
A counter in a database is not a financial ledger. usertrust uses the same double-entry, two-phase commit pattern that banks use. PENDING holds reserve the budget atomically. POST settles. VOID releases. The audit chain is hash-linked and Merkle-provable.
| Feature | usertrust | LiteLLM | Portkey | Langfuse |
|---|---|---|---|---|
| Financial ledger | TigerBeetle | Counter | Counter | Observation |
| Two-phase spend | PENDING/POST/VOID | No | No | No |
| Hash-chained audit | SHA-256 | No | No | No |
| Merkle proofs | RFC 6962 | No | No | No |
| Policy engine | 12 operators | Basic rules | Basic rules | No |
| PII detection | Luhn + regex | No | No | No |
| Circuit breakers | Per-provider | Global | Per-provider | No |
| Offline-first | Local vault | Proxy required | Proxy required | Proxy required |
| Open source | Apache 2.0 | Apache 2.0 | Proprietary | Apache 2.0 |
Budget enforcement is local. Connect a replicated TigerBeetle cluster for production (see Running TigerBeetle), or pass dryRun: true to skip the ledger (audit and policy still run). Remote proxy mode is not implemented — passing proxy to trust() or createGovernor() throws.
Try it first with dryRun: true, as in the example at the top. For full mode, run TigerBeetle as below.
Full mode needs a TigerBeetle replica at 127.0.0.1:3001, the default address. Run the TigerBeetle release that matches the ledger client your install resolved. usertrust depends on tigerbeetle-node ^0.17.9, so a later install can resolve a newer 0.17.x client, and a replica evicts a client whose release it does not support (client_release_too_high for a client newer than the replica). npm ls tigerbeetle-node in your project shows the client's version (0.17.9 when this was written); set TB_VERSION below to it. These commands were run on Linux on arm64 with the 0.17.9 tigerbeetle-aarch64-linux.zip asset; for another platform, take the asset for your platform from the release with that version.
TB_VERSION=0.17.9 # the tigerbeetle-node version `npm ls tigerbeetle-node` shows
curl -sSL -o tigerbeetle.zip "https://github.com/tigerbeetle/tigerbeetle/releases/download/${TB_VERSION}/tigerbeetle-aarch64-linux.zip"
unzip tigerbeetle.zip
./tigerbeetle format --cluster=0 --replica=0 --replica-count=1 --development 0_0.tigerbeetle
./tigerbeetle start --addresses=3001 --development 0_0.tigerbeetleFormat the data file once; start the replica each time you need it.
These commands start ONE replica in --development mode: a local full-mode run, not a production deployment. A single replica has no replication and no high availability: lose its data file and the ledger is gone. For production, deploy a replicated TigerBeetle cluster following TigerBeetle's own guidance (cluster recommendations, deploying), and give the governor all the replica addresses (tigerbeetle.addresses).
TigerBeetle and its client need io_uring on Linux, and Docker's default seccomp profile blocks it; with it blocked, the client cannot start. In a container, run with --security-opt seccomp=unconfined (or a seccomp profile that allows io_uring). On a host where kernel.io_uring_disabled is set, it must be 0.
Standalone verification with zero dependencies:
npx usertrust-verify .usertrustVault integrity: VERIFIED (UNANCHORED — internal consistency only)
Chain length: 847 events
Merkle root: a3f8c1...d92b
Hash algorithm: SHA-256
First event: 2026-03-01T08:12:44.000Z
Last event: 2026-03-16T14:33:21.000Z
All hashes: valid (847/847)
The verify package has zero runtime dependencies. It reads JSONL, recomputes SHA-256 hashes, and checks the chain. Anyone can verify a vault without trusting the usertrust SDK.
usertrust-verify takes the path to the .usertrust directory; usertrust verify takes the project root that contains it. Giving usertrust-verify the project root reports a missing audit directory, not a tampered chain.
Known issue in 4.1.0: on a vault with no external anchor, deleting the most recent records is reported only until the next governed call from a freshly started process; after that, verification passes over the shortened chain. An anchor catches the deletion of the records it covers (those up to the tree size it recorded); records appended after the last anchor are still unanchored. Emptying events.jsonl while its .meta sidecar remains is not silent: the next append continues from the sidecar's hash and verification reports the gap. More generally, someone who can rewrite both the vault file and its sidecar can only be caught by evidence held outside the vault: an external anchor, or receipts you retained. Enable external anchoring if the chain is your evidence. See LIMITATIONS.md section 8.
With external anchoring enabled, verification goes further: the auditor fetches signed checkpoints from an append-only store the operator cannot rewrite and pins the public key out-of-band — so even a fully re-hashed rewrite of the events an anchor covers fails:
usertrust anchor init # once: mint identity, pin the key
usertrust anchor now --sink-file /mnt/worm/anchors.jsonl # from cron/CI, or in-process
# or publish straight to S3, after checking the store really refuses deletes
usertrust anchor doctor --sink-s3 bucket=audit-anchors,region=us-east-1
usertrust anchor now --sink-s3 bucket=audit-anchors,region=us-east-1
npx usertrust-verify .usertrust --anchors anchors.jsonl --pubkey root.pem --require-external-anchorRemoving usertrust is more than npm uninstall. What it leaves behind, and where:
- Per project: delete
.usertrust/. It holds the audit chain, receipts, spend ledger, config and, if you gave one toinit, your provider key in.usertrust/.env. It also holds the vault's identity and anchor state; read the anchor-key bullet below before deleting it if you want to continue an anchor chain. - Packages:
npm uninstall usertrust usertrust-server usertrust-verify(whichever you installed). - Anchor signing key:
usertrust anchor initwrites the private key outside the vault, at~/.usertrust/keys/<vaultId>.anchor.pem(mode 600). Deleting.usertrust/does not delete it.usertrust anchor rotatealways writes each new key to~/.usertrust/keys/<vaultId>.anchor.<timestamp>.pem, even when the first key was placed elsewhere with--key-file; a key at a--key-filepath stays wherever you put it. Each of these survives removal too. Keeping the keys does not by itself keep the anchor chain: continuing it needs the vault's own identity and anchor state (under.usertrust/) as well as the keys, and a reinitialised vault mints a new id and keypair. Delete the keys securely if you are retiring the vault. Also remove any anchor store you created, such as the file given to--sink-file. - usertrust-server: stop the process, then delete its
stateDir(one vault per tenant) and its config and tenant-key files. Versions up to and including 4.1.0 also wrote a.usertrust/patterns/folder in the server's working directory, outsidestateDir; delete it if it is there. - Claude Code plugin: remove the plugin with Claude Code's plugin commands, then delete the configuration file under
~/.config/usertrust/(it holds the server key), the plugin's state directory (thestateDirin the configuration file; with no configuration file,$UT_CC_STATE_DIR, elseusertrust-ccunder$CLAUDE_CONFIG_DIR, else~/.claude/usertrust-cc; and when the file is missing, empty or refused, a session's gap records can fall back to.claude/usertrust-ccunder the account's home as the system records it, whatever$CLAUDE_CONFIG_DIRsays), and~/.local/state/usertrust/(per-session pins, which can also hold the server key). Each pin in~/.local/state/usertrust/sessions/records its session's state directory and keeps it even after the configuration file is gone, so read the pins'stateDirfields and delete those directories BEFORE you delete the pins. A later plugin session prunes pins idle for 30 days, so do not wait for that. If the server key may remain on a shared machine, rotate it.
Where ~ is. The anchor key lives under the $HOME of the account that ran usertrust anchor init (Node's os.homedir()). The plugin looks for its configuration file and .local/state/usertrust under the account's home as the system records it (os.userInfo().homedir), which can differ from $HOME under sudo or in some containers. Find it with node -p "require('os').userInfo().homedir" and check both places before concluding the files are gone. The plugin's state directory defaults to usertrust-cc under $CLAUDE_CONFIG_DIR when that variable is set, and under ~/.claude otherwise.
Apache 2.0
usertrust™ is a trademark of Usertools, Inc.