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), or 7d / 30d style
    • since / until (query, optional): explicit bounds; override window

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 of origin (default), provider, model, origin_id, conversation, day
    • window / since / until (query, optional): as above
  • Response: { "success": true, "groupBy": "origin", "rows": [ { "bucket": "goal_task", ...cost triple } ] }
  • Errors: 400 if groupBy is 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: 404 if 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). Marker prd122_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_datanodes[].parameters) — the same place the run read them from. Marker prd122_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_executions never 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_evaluations records neither provider nor model, and nothing else can supply them.

  • credits_used is not money. Across agent_executions, workflow_executions and 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_calibration corrects 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 type
    • status (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 with balance:0.
  • Response:
{ "success": true, "wallet": { ... } }

Top Up Root Wallet

POST /root/topup

  • Authentication: Required
  • Body:
{ "amount": 1000, "note": "monthly_recharge" }
  • Description: Adds amount to the user's root wallet balance and writes a topup ledger 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: 400 on invalid amount.

Get Single Wallet

GET /:id

  • Authentication: Required
  • Response:
{ "success": true, "wallet": { ... } }
  • Errors: 404 not found, 403 forbidden

Get Wallet Ledger

GET /:id/ledger

  • Authentication: Required
  • Parameters:
    • limit (query, optional): max 1000, default 200
  • 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), optional source_kind / source_id linking 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.