Haddon
Using a coding agent? Give it this context first: what Haddon is, how to authenticate, what to ask the service before writing, and how it refuses.
An agent loads bank statements, books invoices and accruals, files the documents behind them, reconciles each account and closes each period over an HTTP API. It comes with a web interface where people can review the books, correct entries and close periods. If you know Xero, think of its ledger, reconciliation and close, with an agent doing the data entry.
The API assumes the agent will sometimes be wrong. Every write is a named command, validated before it lands and recorded with who made it and why. Posted entries are never edited, only reversed or corrected, and a closed period refuses changes until someone reopens it. Each legal entity’s books are one SQLite file, and they import from and export to hledger journals, checked by a pinned hledger binary.
Haddon is pre-1.0. The schema and API are stable enough to build on, but we don’t make compatibility promises yet.
Try it
Section titled “Try it”You need Python 3.12 or later.
git clone <repository-url> haddon && cd haddonpython3 -m venv .venv && .venv/bin/pip install --upgrade pipmake install
# One set of books for one entity, with a starter chart of accountsbin/haddon-init var/acme.db --id acme --name "Acme Ltd" --legal-form Ltd \ --country US --base-currency USD --base-precision 2
# A configuration naming that file and one agent credentialTOKEN=$(python3 -c 'import secrets; print(secrets.token_hex(24))')cat > var/haddon.json <<EOF{ "entities": {"acme": "acme.db"}, "tokens": {"$TOKEN": {"actor": "agent:bookkeeper", "entities": ["acme"]}}, "cursor_keys": [{"id": "k1", "secret": "$(python3 -c 'import secrets; print(secrets.token_hex(32))')"}]}EOF
bin/haddon-serve --config var/haddon.json # serves on 127.0.0.1:8080In another shell, with the same TOKEN, post an invoice:
curl -s -X POST http://127.0.0.1:8080/v1/entities/acme/entries \ -H "Authorization: Bearer $TOKEN" -H "Idempotency-Key: invoice-0041" \ -H 'Content-Type: application/json' -d '{ "dt": "2026-04-01", "narration": "Invoice 0041, services for April", "reason": "the April invoice was raised on the 1st", "legs": [ {"account": "assets:receivables", "commodity": "USD", "amount": "7316.67"}, {"account": "income:sales", "commodity": "USD", "amount": "-7316.67"} ]}'The reply (201 Created, trimmed) has the posted entry, attributed to the token’s actor. Amounts go in as
decimal strings and come back as integer millionths:
{ "entry": { "id": "e-b3528913b5d84e7db6b3d25403632652", "status": "posted", "created_by": "agent:bookkeeper", "postings": [ {"seq": 1, "account_path": "assets:receivables", "commodity": "USD", "amount_micros": "7316670000"}, {"seq": 2, "account_path": "income:sales", "commodity": "USD", "amount_micros": "-7316670000"} ] }}Send it again with the same Idempotency-Key and you get the same body back with Idempotency-Replayed: true,
not a second invoice. Send "100.001" and it is refused rather than rounded, with an error a model can act on:
{"error": {"code": "precision", "message": "legs[0].amount: '100.001' has 3 decimal places; USD supports 2", "retryable": false, "details": [{"path": "legs[0].amount", "reason": "unsupported_precision", "places": 3, "commodity": "USD", "precision": 2, "value": "100.001"}], "request_id": "req-ac4bf1119ea24f4398b6af0147c7cfa7"}}Quickstart carries on from here: correcting the entry, loading a bank statement, explaining its line and closing the month.
Giving it to an agent
Section titled “Giving it to an agent”Each write in /openapi.json has its command name as its operationId, so tool definitions come straight out
of the schema. examples/haddon_tools.py does that with the standard library and
is meant to be copied into your harness:
from haddon_tools import load_tools, call
tools, routes = load_tools("http://127.0.0.1:8080", allow={"post_entry", "correct_entry", "book_line", "ignore_line"})
# pass `tools` to the model; then, for each tool call it makes:result = call(routes, tool_call.name, tool_call.input, entity="acme", token=AGENT_TOKEN, call_id=tool_call.id)It uses the tool call’s id as the Idempotency-Key, so an HTTP retry can’t post twice, and it hands refusals
back to the model instead of raising them. allow is how you keep close_period for a person if that’s your
process. GET /v1/entities/{id}/capabilities tells an agent what this installation serves, how to write amounts
and dates, and the page limits. Haddon doesn’t ship an MCP server; the
agent guide covers reads, errors and the guardrails we recommend.
This is the whole of call() from examples/haddon_tools.py:
def call(routes: dict[str, tuple[str, list[str]]], name: str, args: dict[str, Any], *, entity: str, token: str, call_id: str, base: str = "http://127.0.0.1:8080") -> dict[str, Any]: """Run one tool call and return the JSON reply, success or refusal.
The tool call's own id is the ``Idempotency-Key``: if the HTTP request is retried after a timeout, the service replays the first result instead of writing twice. A refusal is returned rather than raised, because the error envelope (``code``, ``message``, ``details``) is what the model needs to read to fix its call. """ path, params = routes[name] url = base + path.replace("{entity_id}", entity) for p in params: url = url.replace("{" + p + "}", urllib.request.quote(args[p], safe="")) request = urllib.request.Request( url, method="POST", data=json.dumps(args["body"]).encode(), headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json", "Idempotency-Key": call_id}) try: with urllib.request.urlopen(request) as response: return json.load(response) except urllib.error.HTTPError as refused: return json.load(refused)Read next
Section titled “Read next”For tools: /agent-context.md is the short version for an agent’s instructions, /llms.txt lists every page as markdown, /llms-full.txt has all of it in one file, and /openapi.json is the API schema.