AI Decision System
Replayable, offline-verifiable records for AI-assisted go/no-go decisions: experiment promotions, typed model answers and human-approved actions.
AI 에이전트를 위한 금융 거버넌스. 관리되는 LLM 호출은 검증 가능한 영수증이 있는 변조 방지 원장 거래가 된다
financial governance for AI agents. every governed LLM call becomes a tamper-evident ledger transaction — with a receipt anyone can verify.
개발 도구TypeScript · JavaScriptGitHub @usertools-ai온라인운영자 미확인
써 보셨어요? 한 번만 눌러 주세요. 로그인은 필요 없어요.
AI Financial Governance SDK — every LLM call is a double-entry transaction
저장소 README에서 가져옴
README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
Financial governance for AI agents. Every governed LLM call becomes a tamper-evident, auditable transaction.
That's it. One function wraps any supported LLM client. Every call is metered, audited, and policy-checked.
The receipt returned from every governed call:
bash
npx usertrust initREADME 전문 보기 ↗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.mjs
or 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:
// A real capture, not an illustration: scripts/capture-evidence.mts runs this
// against a real TigerBeetle and commits what comes back. `usage` and
// `pricing` are the two halves of the reconciliation surface — ceil(sum(counts
// x rates / 1000)), floored at 1, reproduces `cost` from the record alone.
{
"transferId": "tx_msm19wxa_a107246b",
"cost": 104,
"budgetRemaining": 49896,
"auditHash": "f4a15bc63fc64bcdab2dcca67718d6a89469311e01cb16fa9dd7bb0c31286654",
"chainPath": ".usertrust/audit",
"receiptUrl": null,
"settled": true,
"model": "claude-fable-5",
"provider": "anthropic",
"timestamp": "2026-08-09T16:45:13.634Z",
"usageSource": "provider",
"usage": {
"inputTokens": 317,
"outputTokens": 131,
"cacheReadTokens": 87,
"cacheWriteTokens": 43
},
"meter": {
"costBasis": "usd-proxy",
"rateSource": "table"
},
"pricing": {
"appliedRates": {
"inputPer1k": 100,
"outputPer1k": 500,
"cacheReadPer1k": 10,
"cacheWritePer1k": 125
},
"tableVersion": "2026-08-09"
}
}
For an endpoint classified as cloud, a model id written the way a gateway or cloud platform writes it (a Bedrock, Vertex or OpenRouter id, a -latest alias without a row of its own, or an id in another case) is priced at the fallback rate, the table's dearest, and reports, exports and the dashboard can show its provider as unknown. To refuse such a call instead, set unknownModelPolicy to "deny". See LIMITATIONS.md section 16 for how to price such an id.
npm install usertrust @anthropic-ai/sdk
npx usertrust init
npx 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"); // CJS
The 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"
}
pii are read from the config file trust() loads: .usertrust/usertrust.config.json by default, or the file you pass as configPath, which is read INSTEAD of the default (edit the one in use, or a setting such as pii: "block" stays off). See Config. pii can also be passed to trust() itself, e.g. trust(client, { pii: "block" }), which overrides the file; any option trust() does not declare is refused by name rather than ignored.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, use usertrust-server.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, run usertrust-server (see packages/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)
usertrust snapshot restore <name> writes the snapshot's files over the live ones, so events appended after the snapshot are dropped, with no warning and no backup, and exits 0 even when it refuses; snapshot create takes no writer lock. Stop every usertrust process that writes the vault before you create or restore a snapshot. Read the Snapshot restored: line, or parse the --json output and test .success === true, not the exit code. See LIMITATIONS.md section 15.
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.fish
All 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:
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.tigerbeetle
Format 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 .usertrust
Vault 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, present in current releases (first documented in 4.1.0): an audit log whose newest events were removed can read as verified. Without anchors the chain is checked only against itself, so a vault whose newest events were cut, and whose .meta file was deleted or rewritten to match, passes usertrust verify and usertrust-verify (exit 0, UNANCHORED). If the .meta file is left alone, the cut is reported only until the next governed call from a freshly started process; after that, verification passes over the shortened chain. To detect a removal, enable anchoring and run with --anchors <file> and --pubkey <pem>: a rollback below an anchor then fails with ANCHOR_MISMATCH. Without --pubkey the anchors are not checked, and the vault still passes as ANCHOR_UNVERIFIABLE. usertrust verify --receipts <file> also fails (exit 1, INCOMPLETE) when a receipt you kept names an event the log no longer holds. 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. 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-anchor
Removing usertrust is more than npm uninstall. What it leaves behind, and where:
.usertrust/. It holds the audit chain, receipts, spend ledger, config and, if you gave one to init, 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.npm uninstall usertrust usertrust-server usertrust-verify (whichever you installed).usertrust anchor init writes the private key outside the vault, at ~/.usertrust/keys/<vaultId>.anchor.pem (mode 600). Deleting .usertrust/ does not delete it. usertrust anchor rotate always 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-file path 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.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, outside stateDir; delete it if it is there.~/.config/usertrust/ (it holds the server key), the plugin's state directory (the stateDir in the configuration file; with no configuration file, $UT_CC_STATE_DIR, else usertrust-cc under $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-cc under the account's home as the system records it, whatever $CLAUDE_CONFIG_DIR says), 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' stateDir fields 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.