usertrustThe open-source ledgerfor agent spend

Open-source, Wall Street grade double-entry ledger that puts agent spend under a budget and on the record.

price
Free
providers
Anthropic · OpenAI · Google
books
Kept
in action

where the money went

The ledger on a demo vault: one line per model call, any call opened to its cost and its chain, and spend day by day.

what is a ledger

a ledger is the book of accounts

A ledger is a list of money in and out, one line per event, never edited. usertrust keeps one line per model call: what was held, what was spent, what it was for.

AI agents now spend money the same way. A call to a model costs something, and an agent can make thousands of calls while nobody is watching. The provider’s bill gives you one total at the end of the month. It does not say which task spent what, and it cannot stop a spend that has not happened yet.

usertrust is a ledger for that spending. You give an agent a budget. Before a call, usertrust sets aside what the call may cost. If the budget cannot cover it, the call does not run. When the call is over, the cost is written against the budget, with the model, the tokens and the prices that produced it.

why you need one

the line nobody could explain

Overnight, an agent retried the same call 47 times. The provider billed it. Your logs didn’t see it. Finance asks what the $500 was for — and there is nothing to dispute, nothing to replay, and nothing that would have stopped night two.

receiptnone issued
{
  agent:        —,
  task:         —,
  budget hold:  none,
  auditHash:    —,
  cost:         $500.00,
  verifiable:   no
}
unreceipted. paid anyway.
what it does

each call goes through three steps

A hold, the call itself, then a charge or a release. The banking pattern: held, then settled or voided.

Each governed call goes through three steps. First usertrust holds what the call may cost against the budget. Then the call runs. Then the hold is turned into a charge if the call worked, or released if it failed. What is available to the next call is budget − settled − Σ(holds), so two agents sharing a budget cannot both spend the same money: the first hold that would exceed what is actually available throws.

held
settled
voided
the racetwo agents, one budget
// illustrative — the sdk opens the hold itself, inside trust()
// budget: 50,000 usertokens ($5.00)
const budget = 50000

agent1.hold(30000)
// available = 50,000 − 30,000 = 20,000

agent2.hold(25000)
// 25,000 > available (20,000) — the ledger refuses the hold
throw new InsufficientBalanceError("need 25000, have 20000")

budget 50,000 usertokens ($5.00) · packages/core/src/shared/constants.ts — DEFAULT_BUDGET.

get started

install it, create the vault, wrap your client

Three steps from an empty project. You keep your provider keys and your billing; usertrust sits in front of the client you already use.

01

install it

One dependency beside the provider SDK you already use.

02

create the vault

Writes .usertrust/ — config, policy rules, an empty chain.

03

wrap the client you already have (starter mode)

Prompts and control flow untouched. The signature gains what it cost. Starter mode, until the one-command setup ships: dryRun skips the ledger, so only this session’s budget moves.

agent.tstypescript
import Anthropic from "@anthropic-ai/sdk";
import { trust } from "usertrust";
const client = await trust(new Anthropic(), {
  budget: 50_000, dryRun: true,     // $5.00 · ledger deferred
});

const { response, receipt } = await client.messages.create({
  model: "claude-fable-5",
  max_tokens: 1024,
  messages: [{ role: "user", content: "Analyze this contract" }],
});
await client.destroy();   // required
first receipt

your first receipt, in plain english

This is what comes back from the code above: the receipt for one call, read out in plain English. Switch to json for the raw record.

The code
agent.tsfrom get started
import Anthropic from "@anthropic-ai/sdk";
import { trust } from "usertrust";
const client = await trust(new Anthropic(), {
  budget: 50_000, dryRun: true,     // $5.00 · ledger deferred
});

const { response, receipt } = await client.messages.create({
  model: "claude-fable-5",
  max_tokens: 1024,
  messages: [{ role: "user", content: "Analyze this contract" }],
});
await client.destroy();   // required
What comes back
receipt
TransactiontransferId
tx_msm19z93_e603a1a2This call’s own reference, the way a line on a bank statement has one. Nothing else can carry it.
What it costcost
104 usertokens · $0.0104Just over a penny. Not an estimate and not a share of a monthly bill — what this one call took.
What is leftbudgetRemaining
49,896 of 50,000Read after the cost settled, so it is an observation rather than a promise about what happens next.
Tamper sealauditHash
08c22f61…f030f8622The hash of this call’s llm_call event in the chain — model, tokens, cost, the rates that priced them, the transaction — linked to the event before it. Alter one character of that event and the fingerprint stops matching. It seals the chain entry; it is not a hash of the fields of this receipt.
Where it is keptchainPath
.usertrust/auditA folder inside your own project; the chain itself is an append-only events.jsonl inside it. Not our servers, not a dashboard you rent — you hold the book.
Settledsettled · true
Settled against the budgetThe cost is held before the call and settled after it; a call that failed would have voided the hold instead. With dryRun the ledger is skipped and only the session budget moves.
Who did the workmodel · provider
claude-fable-5 · anthropicWhich model answered, and whose. Switch either one and the next receipt says so.
The mathsusage · pricing
4 token counts × 4 ratesThe receipt carries both halves, so the cost can be recomputed from the record alone — see below.
input317×100÷1k31.700
output131×500÷1k65.500
cache read87×10÷1k0.870
cache write43×125÷1k5.375
 sum103.445
→cost 104

104 usertokens, 1 = $0.0001 — just over a cent. Counts and rates are both the receipt’s own, so the number is checkable from the record alone. That is the difference between a log and an account.

refusals

BLOCKED is a feature

The gate runs before the provider is ever called. A denial throws, the provider is never reached, and no receipt is returned — but the refusal is not silent. Denials don’t get receipts. They get chain events.

policy denial
policy rule deny · hard
name: block-wire-transfers
effect: deny
enforcement: hard
conditions:
  - field: "action_name"
    operator: "eq"
    value: "wire_transfer"

one of 12 policy field operators · packages/core/src/shared/types.ts — FieldOperator union members

caller what the SDK sees
// thrown before the call, not returned after it
throw PolicyDeniedError
receipt: none
provider: never called
chain what the ledger records
kind: "policy_denied"
decision: "deny"
denialClass: "policy"
policyRules: [{ name: "block-wire-transfers" }]

no receipt means no spend, no ledger transfer, no line in the cost total — only the denial itself is written to the chain.

check it

tamper with one byte. the chain breaks there.

Every audit entry carries the hash of the one before it. Change a single byte and the verifier finds exactly where it broke. Rewrite every hash after it too, and only a chain hash published outside your systems catches it.

the chain verifies itself

each entry in the audit chain stores the hash of the entry before it. a verifier recomputes every hash and walks the chain in order — each mismatch names its broken link — entry number and event id — and the walk keeps going, so every break is reported, not just the first.

and can be anchored beyond your infra

the same chain hash can be published somewhere outside your own systems, so a tamper attempt has to beat not just your database but that outside record too.

verify.usertrust
# the vault: three entries, each carrying the hash of the one before it
#   entry 1  hash 08c22f61…  previousHash 00000000…
#   entry 2  hash 71e4a9c0…  previousHash 08c22f61…
#   entry 3  hash c4d18f77…  previousHash 71e4a9c0…
$ npx usertrust-verify .usertrust

Vault integrity: VERIFIED (UNANCHORED — internal consistency only)
Chain length: 3 events
Merkle root: 5d0b3c9e…
Hash algorithm: SHA-256
All hashes: valid (3/3)

# one byte changed inside entry 2 (amount 104 → 904), and entry 2 re-hashed to 9b21d5e3…
# entry 3 still points at 71e4a9c0… — the link is what broke
$ npx usertrust-verify .usertrust

Vault integrity: FAILED
  - Event 3 (3f9c1a7e-…): previousHash mismatch. Expected 9b21d5e3…, got 71e4a9c0…
Chain length: 3 events
Merkle root: 9e47a1d2…
Hash algorithm: SHA-256
All hashes: valid (3/3)

tamper-evident, not tamper-proof — detection, not recovery.

chain path .usertrust/audit · hash prefix 08c22f61…

check it yourself

don’t take our word for it

The verifier is a separate, minimal program. These are the two numbers that make it trustworthy on its own terms.

0runtime dependencies in the verifier.
verifier runtime dependencies: 0 — packages/verify/package.json, dependencies key count
0the verifier owes us nothing.
imports the verifier takes from core: 0 — packages/verify parity contract (AGENTS.md), import audit

the first zero is a dependency audit — nothing to compromise because nothing is installed. the second is the one that’s load-bearing: 0 imports from packages/core anywhere in packages/verify. core is what produces the hashes in the first place; verify recomputes them from the same audit file on its own, without importing core to do it. if the two shared code, the verifier would just be checking its own arithmetic. because they don’t, it’s checking usertrust’s.

usertrust-verify0 deps · 0 imports from core
$ npx usertrust-verify .usertrust
# recomputing every hash — 0 imports from core
Vault integrity: VERIFIED (UNANCHORED — internal consistency only)
Chain length: 8 events
Merkle root: 4f7ad669…2f4514c8
Hash algorithm: SHA-256
First event: 2026-08-09T16:45:13.602Z
Last event: 2026-08-09T16:45:16.565Z
All hashes: valid (8/8)

transcript as captured by the SDK’s scripts/capture-evidence.mts · site/app/evidence/verify-transcript.json — 8-event vault, ledger mode

the first public receipt is live — verify it yourself, then read the work it receipted.

under the hood

what is underneath

providers

the same receipt from three providers

Three frontier models, three receipts, one ledger. usertrust sits in front of the call, not inside the model — so the receipt shape does not change when the provider does.

the quickstart above showed one anthropic call settle end to end. that was not a special case. route the same governed call through openai or google and the SDK hands back the same object — model, cost, settled, auditHash — written to the same ledger. one contract, multiple providers.

anthropicclaude-opus-5
// → { response, receipt }
model: "claude-opus-5",
cost: 212,
budgetRemaining: 49684,
settled: true,
auditHash: "3b7e91a4c05d8e12…5d02c8f61b9a4"
openaigpt-5.6-sol
// same shape
model: "gpt-5.6-sol",
cost: 187,
budgetRemaining: 49497,
settled: true,
auditHash: "a41c07d2e8b35f96…9e3b6f1a8c0d7"
googlegemini-2.5-pro
// same shape
model: "gemini-2.5-pro",
cost: 96,
budgetRemaining: 49401,
settled: true,
auditHash: "7f20e5b8d1a93c46…c14a90d3e7f25"

budget carried on from the first receipt (49,896 left) · one chain, three providers

priced across 26+ models · packages/core/src/ledger/pricing.ts — PRICING_TABLE keys

forgery

every way we know to forge a ledger

Every commit in this repo tries to forge the ledger on purpose — then hands the attempt to a verifier that owes core nothing.

every forgery fails. every legitimate operation verifies.

65harden suites

harden suites: 65 · packages/core/tests/harden/**/*.test.ts — test-file count

3,706test cases

test cases: 3,706 · packages/**/*.test.ts — count of it()/test() openers

8,843assertions

assertions: 8,843 · packages/**/*.test.ts — count of expect( calls

37invariants enforced

invariants: 37 · AGENTS.md — count of "Prevents:" clauses

all four counted at usertrust v3.4.0 (a1bce25) on 2026-09-05 with the SDK’s scripts/capture-evidence.mts rules — its shipped facts.json lags at 3.2.0

Don’t trust us — recompute us.

get started

open your ledger

Install it and create the vault, and the next call your agent makes comes back with something you can check.

npm install usertrust npx usertrust init

your keys. your billing. your evidence.

start in dry-run — TigerBeetle skipped, audit chain and policy gates still run.

setup2 min
runtime deps in the verifier0
licenseApache-2.0

total surprises ··· 0

where these were counted

setup — docs quickstart, stated duration on the dry-run path

runtime deps — packages/verify/package.json, dependencies key count

license — packages/core/package.json, license: Apache-2.0

run it where you need it

local SDK shipping today npm install usertrust
self-hosted control plane shipping today npx usertrust-server
managed proxy on request request access →