Books
Package books is double-entry accounting: chart of accounts, ledger, bank reconciliation, and the reports that prove the books balance.
Package books is double-entry accounting: chart of accounts, ledger, bank reconciliation, and the reports that prove the books balance.
| Base URL | https://api.hanzo.ai |
| Operations | 25 |
| Auth | Authorization: Bearer $HANZO_API_KEY |
Specification
HIP-1001 · Books — The Double-Entry Ledger — Active · read the specification →
/v1/books is double-entry accounting for an org: a chart of accounts, an
append-only general ledger, bank feeds with reconciliation, receipt capture, and
the reports that prove the books balance. It is implemented in hanzoai/cloud at
apps/books. This HIP states the two properties that make it trustworthy — that
it records money it never moves, and that every posting balances or is refused.
Motivation
The money plane holds balances and the customer-facing billing surface projects them. Neither keeps books: a chart of accounts, a general ledger, revenue recognition, a trial balance. Without those there is no statement anyone can audit and no way to answer a question about last quarter that survives a restatement.
Specification
The key words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are to be interpreted as in RFC 2119.
The surface, and what stays untyped
Every address is under /v1/books — twenty-one prefixes enumerated in the
manifest row (manifest/apps.go:412), twenty-two paths published in the
capability's own subset (plugin/books/openapi.json). Twenty operations are
typed; five are untyped by design and each is named at its registration and held in a ledger
test (apps/books/projection_test.go): the three raw-byte uploads —
POST /v1/books/scan, POST /v1/books/inbox and POST /v1/books/bank/import
take the receipt or statement file ITSELF as the body, which no JSON In can
describe, so each declares its byte request through openapi.Register with
openapi.Binary — and two link stubs that answer an unconditional 501 and
deliberately declare nothing. The two ledgers must sum to the served surface,
so a route added untyped goes red rather than unlisted.
Free, metered nowhere, publishing nothing
Every books route is free — the plugin declares Price: cloud.Free
(plugin/books/main.go:21) and no handler gates or meters spend. Books spends
no provider's money: it restates the ledger feed it reads. It publishes no
events on the bus, so a customer's webhooks receive nothing from this
capability, and it emits nothing to observability beyond the request span
every route already gets — there is no tracer, meter or log surface of books'
own in apps/books.
Stage
Books is a vertical application, not the agentic-OS core: its stage is
beta, declared in the manifest row (manifest/apps.go:412, Stage: Beta;
HIP-0139 §8).
Upstreams
One OSS library is embedded: rsc.io/pdf (BSD-3-Clause), which parses bank
statements on the PDF import path (apps/books/import_pdf.go:46) — the text
extraction survives in HEAD, nothing else of it does. The Plaid and Teller
bank connectors (apps/books/plaid.go, apps/books/teller.go) are
hand-written read-only clients to those services, not forks of anything.
It records money; it never moves money
Three sources post into the ledger: the platform's own transaction feed, a
read-only bank connector, and a reviewed receipt capture. All three land through
one choke point, store.post (apps/books/store.go:238), and none of them can
mint a deposit, a credit or a payout (apps/books/books.go:14-19).
This is the invariant the whole capability rests on: books can restate money, never create it. Any new posting source MUST arrive through the same choke point and MUST carry no authority to move funds.
A voucher balances or it is refused
A posting is a set of legs in exact integer minor units. Floating point is
forbidden: cents are exact under add, subtract and negate, which are the only
operations double-entry performs, so there is no rounding error to accumulate
(apps/books/gl.go:11-14).
The pipeline is pure and database-free (apps/books/gl.go:55-65): merge legs on
the same account, reduce each leg to a single non-negative side, absorb a residual
difference within the round-off allowance, then assert Σdebit == Σcredit. A
difference larger than the allowance MUST fail closed rather than be plugged
against equity (apps/books/gl.go:17-21). The allowance exists only to soak up a
one- or two-cent artifact of an upstream split.
Idempotency is (sourceKind, sourceID): the same source event posts exactly once,
so replaying a feed is a no-op rather than a duplicate.
Each org's books are physically separate
Every read resolves the caller's own org from the validated principal, and each
org's ledger is its own database file, with the sandbox ledger a second, separate
file (apps/books/books.go:22-26, apps/books/books.go:44-47). A test-mode row
therefore cannot reach real revenue, and one org cannot read another's ledger even
in the presence of a query defect, because the other org's rows are not in the
file being queried.
The language surface may rephrase a figure; it may never source one
The plain-language question surface routes deterministically to metrics computed
from the ledger, and the model — when one is configured at all — rewrites prose
without touching a number (apps/books/ask.go:4-16). With the model absent or
down, the figures are identical. The brain is strictly read-only and MUST NOT
reach the posting path.
Bank credentials live in the key service or the operation fails
Connector access tokens are stored and fetched through KMS and MUST NOT be
persisted in the ledger database. A deployment with no key service wired fails
every credentialed bank operation closed (apps/books/books.go:55-59).
What this refuses
- No manual journal door. Postings come from the three declared sources.
- No float. An amount is integer cents or it does not enter.
- No cross-org read. The tenant is not an input.
- No model-sourced figure. Narration is prose only.
Rationale
The alternative to a separate posting choke point is to let each source write its own legs. It is easier and it is how the imbalance gets in: three writers means three places where the balance assertion can be skipped, and the assertion is the only thing that makes a trial balance mean anything.
The alternative to per-org database files is one database with a tenant column. That works until one query forgets the predicate. Separate files make the forgetting harmless, and the cost is bounded because the ledger is small.
Security Considerations
What an attacker gets from the wrong implementation is one of two things: a
posting source that carries authority to move funds turns a bookkeeping bug
into minted money, which is why every source lands through the one choke point
that cannot mint (apps/books/books.go:14-19); and a tenancy defect here is
not a leak of preferences but of a company's finances.
A ledger is a disclosure surface: revenue, vendors, payroll shape. Tenancy is therefore enforced by physical separation rather than by a filter, and the tenant key is read from the validated principal rather than from any caller-supplied field.
The bank connector is the one component holding a third-party credential. Keeping those in the key service means a copy of an org's ledger file is not a copy of its bank access.
The read-only posture of the question surface is a security property, not only a correctness one: it is what makes it safe to hand a language model an ability to answer questions about the ledger.
Four surfaces
| Surface | Reaches this capability as | Coverage |
|---|---|---|
| REST | books at its own prefix | 25 operations |
| CLI | hanzo books … | 25 of 25 |
| SDK | BooksApi in every published client | 25 methods |
| MCP | tool books on https://api.hanzo.ai/v1/mcp | 24 operations, 0 under the document's own id — ask describe for the rest |
Quickstart
export HANZO_API_KEY=sk-... # console.hanzo.ai → API keysThen the first call — a read that needs nothing but the key. GET /v1/books/gl, operation get_books_gl:
hanzo books glimport { Configuration, BooksApi } from 'hanzoai';
const api = new BooksApi(new Configuration({ accessToken: process.env.HANZO_API_KEY }));
const { data } = await api.getBooksGl();from hanzoai.cloud import ApiClient, Configuration
from hanzoai.cloud.api import BooksApi
client = ApiClient(Configuration(access_token=os.environ["HANZO_API_KEY"]))
result = BooksApi(client).get_books_gl()cfg := cloud.NewConfiguration()
cfg.AddDefaultHeader("Authorization", "Bearer "+os.Getenv("HANZO_API_KEY"))
client := cloud.NewAPIClient(cfg)
resp, _, err := client.BooksAPI.GetBooksGl(context.Background()).Execute()
if err != nil {
return err
}use hanzo_cloud::apis::{configuration::Configuration, books_api};
let mut cfg = Configuration::new();
cfg.bearer_access_token = std::env::var("HANZO_API_KEY").ok();
let result = books_api::get_books_gl(&cfg, Default::default()).await?;import ai.hanzo.cloud.ApiClient;
import ai.hanzo.cloud.api.BooksApi;
ApiClient client = new ApiClient();
client.setRequestInterceptor(b -> b.header("Authorization", "Bearer " + System.getenv("HANZO_API_KEY")));
var result = new BooksApi(client).getBooksGl();curl https://api.hanzo.ai/v1/books/gl \
-H "Authorization: Bearer $HANZO_API_KEY"The door reaches books through the books tool, which names its 24 operations with its own verbs — this one among them, under a name only the door declares. describe explains any of them:
curl -X POST https://api.hanzo.ai/v1/mcp \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "describe",
"arguments": {
"op": "list_book_accounts"
}
}
}'Answers 200 with GLRow[] — ok.
Endpoints
| Endpoint | What it does |
|---|---|
GET /v1/books/accounts | Returns the org's chart of accounts — the seeded fixed chart every posting key in the ledger refers to. |
POST /v1/books/ask | Answers a plain-language question about the caller's own books — "what is my MRR?", "how long is my runway?" — with figures taken from their ledger,… |
POST /v1/books/bank/exchange | Finish connecting a bank account (not yet available) |
POST /v1/books/bank/import | Import a bank statement file into your books |
POST /v1/books/bank/sync | Pulls every connected bank (Plaid/Teller) for the caller's org, maps each fetched transaction to a posting and books it idempotently, then advances… |
POST /v1/books/bank/token | Begin connecting a bank account (not yet available) |
GET /v1/books/bank/transactions | Returns the org's normalized bank transactions, newest first — every row the import and connector paths have ingested, with its amount in exact… |
GET /v1/books/bank/unreconciled | Returns the org's unmatched bank inflows and their open clarifying questions — the queue a human answers so an unexplained deposit is never guessed… |
GET /v1/books/export | Returns the complete financial package for the caller's org over (from, to]: the trial balance, the P&L, the balance sheet, and the GL detail behind… |
GET /v1/books/gl | ListGL returns the org's most recent GL Entry rows, newest first. |
GET /v1/books/inbox | Returns the org's open document queue — everything uploaded but not yet booked, newest first, each with its extracted summary and the confidence the… |
POST /v1/books/inbox | Queue a document for later scanning |
GET /v1/books/metrics | Metrics returns the org's deterministic SaaS-metrics snapshot over an optional (from, to] window — MRR, ARR, revenue, COGS, burn, gross margin, net… |
GET /v1/books/pnl | Returns the org's accrual-basis Profit & Loss over an optional (from, to] window of RFC3339 posting times: recognized revenue, matched cost, and the… |
GET /v1/books/position | Returns the org's Balance Sheet as of to (empty = all time), with the Assets == Liabilities + Equity equation proof. |
GET /v1/books/questions | Returns the clarifying questions the caller's own recent GL raises — the unusual postings a founder should look at (outliers, reversals, round-offs,… |
GET /v1/books/rules | Returns the org's auto-categorization rules, highest priority first. |
POST /v1/books/rules | Creates or updates one auto-categorization rule, keyed by its pattern — writing a pattern that already exists REPLACES that row's category and… |
POST /v1/books/scan/book | Posts a reviewed scanned bill to the ledger. |
POST /v1/books/scan | Scan a receipt or invoice into a proposed voucher |
POST /v1/books/sync | Sync ingests the caller's OWN org from commerce into BOTH ledgers (live and sandbox) and reports how many new vouchers posted to each. |
GET /v1/books/transactions | Returns the org's booked ledger as a single-line register, newest first: one row per voucher, with its date, description, vendor, category, source… |
GET /v1/books/trial | Returns the org's trial balance over an optional [from, to] window of RFC3339 posting times, including the opening/closing columns and the TotalDebit… |
GET /v1/books/vendors | Returns the org's vendor book: each canonical vendor, the alias spellings a receipt may print it under, and the expense account new bills from it… |
POST /v1/books/vendors | Creates or updates one vendor in the org's vendor book, keyed by its canonical name — writing a canonical name that already exists REPLACES that… |
How is this guide?