Base URL
http://localhost:3333/api/· Authentication · Conventions
Ledger Routes
Base path: /api/ledger
The execution ledger (PRD-122): one row per LLM request/response round-trip, across every execution path — orchestrator chat, agent runs, workflow LLM nodes, goal tasks and goal evaluations.
Before this existed, four subsystems each kept their own books and two kept none, so "what did this cost?" was not answerable from AGNT's own data. Every row is written by exactly one function (services/execution/LedgerRecorder.js); backend/src/services/execution/ledgerContracts.spec.js fails the build if a second write path appears.
The cost triple
Every monetary response carries three numbers, and consumers must render all three:
| Field | Meaning |
|---|---|
costUsd |
Charged spend. Excludes subscription-seat usage. |
unpricedCalls |
Calls whose model has no pricing metadata. Their cost is unknown, stored as NULL, and deliberately NOT folded into the total as zero. |
notionalUsd |
What subscription-provider usage (Claude Code, Codex, Gemini CLI…) would have cost on a metered API. Not money charged. |
A total that silently omits unpricedCalls reintroduces the defect this ledger was built to fix, so surfaces should read "$1.23 · 3 calls unpriced" rather than presenting a bare figure.
Get Spend Summary
GET /summary
- Authentication: Required
- Parameters:
window(query, optional):today(default), or7d/30dstylesince/until(query, optional): explicit bounds; overridewindow
Nd means N CALENDAR DAYS back to local midnight — not a rolling N×24 hours. The dashboard usage chart asks for calendar dates and the rollup groups them with DATE(..., 'localtime'), so a rolling window would begin up to 24 hours later than the chart's and the two figures would never reconcile; anyone comparing them would reasonably conclude one was broken. Same boundary, same buckets. Pinned by LedgerRoutes.test.js.
- Response:
{
"success": true,
"costUsd": 3.4212,
"notionalUsd": 0.88,
"uncachedCostUsd": 15.22,
"savedUsd": 11.80,
"notionalUncachedUsd": 4.10,
"notionalSavedUsd": 3.22,
"unpricedCalls": 2,
"calls": 147,
"inputTokens": 1840233,
"outputTokens": 92011,
"cacheReadTokens": 1502000,
"cacheWriteTokens": 210500,
"ledgerHealth": {
"totalFailures": 0,
"byProcess": [],
"thisProcess": { "recorded": 147, "failed": 0, "lastError": null, "scope": "backend", "pid": 24924 }
}
}savedUsd is what prompt caching saved: the same pricing function run with and without the cache breakdown, so the two can never drift. It is negative on a turn that first writes a cache prefix (Anthropic cache writes cost 1.25x/2.0x), which is reported honestly rather than clamped to zero.
Charged and notional are separate axes throughout. savedUsd pairs with costUsd; notionalSavedUsd pairs with notionalUsd. Crossing them would produce arithmetic nonsense, and reporting only the charged axis would make savings a structural zero for every subscription user — the exact audience for whom savings is the only meaningful money on the page.
ledgerHealth.totalFailures is the tripwire. A non-zero value means ledger writes are being dropped and every total above is understated.
It is deliberately cross-process. AGNT runs the workflow engine as a separate OS process (backend/src/workflow/WorkflowProcess.js) from the HTTP API (backend/server.js). Both write LLM calls to the same SQLite file but share no memory, so an in-process counter answering this endpoint would report a serene zero while the workflow process dropped every write — a tripwire that cannot trip for the path most likely to break. byProcess therefore reads from the shared ledger_write_failures table, keyed by process role.
thisProcess is the in-memory counter for whichever process served the request. It is kept because it still works when the database itself is the broken thing, and it carries an explicit scope so it can never be mistaken for a global figure.
Get Spend Breakdown
GET /breakdown
- Authentication: Required
- Parameters:
groupBy(query, optional): one oforigin(default),provider,model,origin_id,conversation,daywindow/since/until(query, optional): as above
- Response:
{ "success": true, "groupBy": "origin", "rows": [ { "bucket": "goal_task", ...cost triple } ] } - Errors:
400ifgroupByis not whitelisted. It is the one value that reaches SQL as an identifier rather than a bound parameter, so the whitelist is what makes it safe.
Origins: chat, agent, goal_task, goal_eval, workflow_node, insight, system.
Get Run Tree
GET /tree/:executionId
The run tree rooted at the given execution, with per-node and subtree cost. Resolves the whole tree from any member, not just the root.
- Authentication: Required
- Response:
{
"success": true,
"rootExecutionId": "uuid",
"nodes": [
{
"id": "uuid",
"parentExecutionId": "uuid|null",
"agentName": "Research Assistant",
"origin": "agent",
"status": "completed",
"provider": "anthropic",
"model": "claude-sonnet-4-5-20250929",
"ledger": { "costUsd": 0.42, "calls": 6, "unpricedCalls": 0 }
}
],
"unattached": [
{ "origin": "goal_task", "originId": "goal-uuid", "costUsd": 1.10, "calls": 12 }
],
"subtree": { "costUsd": 1.52, "notionalUsd": 0, "unpricedCalls": 0, "calls": 18 }
}- Errors:
404if the execution does not exist or belongs to another user.
unattached carries ledger rows that belong to the tree but have no agent_executions row of their own — goal tasks and evaluations. They are real spend, and omitting them would make subtree.costUsd quietly low.
Notes
History before 2026-08-01 was inherited, not fabricated. The ledger began recording on 2026-08-01; on first boot after that it backfills earlier history from two sources, each stamped with the run's original date:
- Agent runs from
agent_executions, copying the cost that was measured at run time (provider, model, tokens and cache split included). Markerprd122_backfill_agent_executions. - Workflow LLM nodes from
node_executions, which stored tokens but never provider or model. Those are recovered from the workflow definition (workflows.workflow_data→nodes[].parameters) — the same place the run read them from. Markerprd122_backfill_node_executions.
Two limitations, stated rather than hidden. The workflow definition consulted is the current one, so a node whose model was changed after a run is priced with today's model; and
node_executionsnever stored a cache split, so those rows price at standard rates and claim zero savings rather than assuming a hit rate. A node deleted from its workflow since the run is left unpriced rather than guessed at.Both backfills are per-row idempotent and resumable: dedup is by "does this execution already have a ledger row", so an interrupted run simply continues on the next boot. Goal-evaluation history is not backfilled —
goal_evaluationsrecords neither provider nor model, and nothing else can supply them.- Agent runs from
credits_usedis not money. Acrossagent_executions,workflow_executionsand goal detail, that column stores wall-clock seconds. It is retained under its historical name because existing UI reads it. Every monetary figure comes from this ledger.Cost is an estimate, not an invoice.
estimate_calibrationcorrects for CLI-backed providers that inject invisible preamble, but surfaces should label the figure estimated.
Wallet Routes
Base path: /api/wallets
Linear-type capability budgets (PRD-091 Layer 3). Each user has a root wallet; sub-wallets are derived via WalletService.allocate and can never duplicate funds because every debit and transfer goes through an atomic guard with a balance >= amount check.
Conservation invariant: across allocate / consume / release cycles, the sum of all active balances is exactly (root topup total) - (total consumed). The agent can rely on this — see WalletService.spec.js invariant tests.
Wallet shape
{
"id": "uuid",
"user_id": "user-uuid",
"owner_type": "user|agent|workflow|tool|run",
"owner_id": "owner-uuid",
"parent_id": "uuid|null",
"kind": "tokens",
"balance": 850,
"status": "active|closed",
"created_at": "...",
"updated_at": "..."
}List Wallets
GET /
- Authentication: Required
- Parameters:
ownerType(query, optional): filter by owner typestatus(query, optional):active,closed
- Response:
{ "success": true, "wallets": [ { ... } ] }Get-or-Create Root Wallet
GET /root
- Authentication: Required
- Description: Returns the user's root wallet (
owner_type:'user',parent_id:null). Creates it on first call withbalance:0. - Response:
{ "success": true, "wallet": { ... } }Top Up Root Wallet
POST /root/topup
- Authentication: Required
- Body:
{ "amount": 1000, "note": "monthly_recharge" }- Description: Adds
amountto the user's root wallet balance and writes atopupledger entry. Idempotent on the API surface — repeated calls add repeatedly, so the agent should confirm with the user before topping up. - Response:
{ "success": true, "wallet": { ... } }- Errors:
400on invalid amount.
Get Single Wallet
GET /:id
- Authentication: Required
- Response:
{ "success": true, "wallet": { ... } }- Errors:
404not found,403forbidden
Get Wallet Ledger
GET /:id/ledger
- Authentication: Required
- Parameters:
limit(query, optional): max1000, default200
- Description: Returns the transaction log for this wallet. Each entry has
amount(negative for debit),op(topup/consume/allocate_in/allocate_out/release_sweep/release_recv), optionalsource_kind/source_idlinking to the asset that spent (e.g. tool, agent run). - Response:
{
"success": true,
"ledger": [
{
"id": "led-uuid",
"wallet_id": "wal-uuid",
"amount": -30,
"op": "consume",
"source_kind": "tool",
"source_id": "web-search",
"note": null,
"created_at": "..."
}
]
}Release Wallet
POST /:id/release
- Authentication: Required
- Description: Sweeps the wallet's remaining balance back to its parent (if any), then marks the wallet
closed. Safe to call multiple times — a second call on an already-closed wallet is a no-op. - Response:
{ "success": true, "wallet": { ... } }- Use case: An agent run finishes; release its sub-wallet so the leftover budget flows back to root.
Notes on consume / allocate
There is currently no public HTTP endpoint for consume or allocate. Those operations live on the server-side WalletService and are invoked by tool executors, the orchestrator, and the scheduler. The HTTP surface is intentionally limited to read + root-topup + release — i.e. the operations a UI or agent on behalf of a user needs. Agent code that needs to debit an arbitrary wallet should call WalletService.consume(walletId, amount) directly, not over HTTP.