If you charge autonomous agents or bill per tool call, receipts arrive faster than anyone can check them. Most of them are fine. The ones that are not cost real money in a way that is invisible until month end:
- a buyer sends 90% of the amount and the invoice gets closed anyway
- a payment lands in the right asset on the wrong network, or the wrong asset entirely
- the same settlement is submitted twice and you owe a refund you never noticed
- a receipt settles after the invoice expired, at a price you no longer offer
- the buyer paid and got nothing — no service call was ever delivered
This agent reads your invoices, your payment receipts and your service-call log,
matches them by invoice_id, and returns one conservative ledger entry per
invoice. It is deterministic: same input, same verdict, no model required.
What it is not
It is not a payment processor, a wallet, or a chain indexer. It never moves money and never touches a private key. It reads three JSON files and tells you which invoices you may safely close, and which ones need a human.
Install
sudo bash install.sh # deps + venv + systemd service
bash install.sh --no-service # venv only
Debian/Ubuntu. Python 3.9+. Standard library only — no dependencies to audit.
Run
python agent.py reconcile \
--invoices data/invoices.json \
--payments data/payments.json \
--service-calls data/service_calls.json \
--out out/reconciliation.json
python agent.py status --report out/reconciliation.json
python agent.py explain --invoice-id inv-metering-api --report out/reconciliation.json
python agent.py run --once --deterministic
init-sample writes a working set of the three input files if you just want to
see the shape:
python agent.py init-sample --dir examples
Statuses
One status per invoice, decided in this order. The first one that applies wins, so you always see the root cause rather than a downstream symptom.
| Status | Meaning | Typical fix |
|---|---|---|
invalid |
no invoice_id, or amount_due is missing or not positive |
fix the invoice |
wrong_asset |
a receipt settled with an asset or on a network the invoice does not accept | re-credit or refund |
expired_invoice |
the receipt settled after expires_at |
reissue at current price |
unpaid |
no receipt references this invoice | wait or chase |
underpaid |
credited receipts do not cover amount_due |
collect the remainder |
duplicate_receipt |
the same settlement was submitted more than once | refund the duplicate |
overpaid |
credited receipts exceed amount_due |
refund the difference |
missing_fulfillment |
fully paid, but no service call was delivered | deliver or refund |
reconciled |
amount, asset, network and fulfillment all match | close it |
Everything except reconciled and unpaid sets escalation_required: true.
The conservative rules
These are the rules that make the ledger trustworthy. They are deliberately strict, and all of them are enforced in code, not in a prompt.
- A receipt is credited only if nothing is wrong with it. Wrong asset,
wrong network, duplicate settlement, or post-expiry arrival ⇒ the money is
reported in
amount_uncredited, and never added toamount_paid. An invoice is never marked paid by money that cannot legally settle it. - Duplicates are detected by settlement identity (
tx_hash), not by row id — across the whole batch. The same hash replayed against a second invoice is still a duplicate. - A receipt whose
invoice_idis not in the batch never creates an invoice. It goes toorphan_paymentsverbatim. - Exposure is never summed across assets.
amount_at_risk_by_assetkeeps USDC and ETH apart, because adding them produces a number that means nothing. - Money owed to you and money you are wrongly holding are separate.
amount_at_riskreports one direction per invoice, in the invoice asset.
Bring your own LLM (optional)
The matcher never calls a model. If a Claude Code, Kimi Code, or Codex CLI is
installed and logged in, llm_adapter.py will use it to add a review note —
suggested actions, dunning replies, a standup digest. No API key: your existing
CLI subscription is the billing relationship.
python llm_adapter.py # shows which backend, if any, is available
PACK_LLM=codex python agent.py reconcile # pin a backend
The model can never change a status. It advises; the deterministic core decides.
Input shape
Three files, all optional fields tolerated. Amount fields accept numbers or numeric strings.
// invoices.json
{"invoices": [{"invoice_id": "inv-tool-calls", "buyer": "agent://checkout-bot",
"amount_due": 12.5, "asset": "USDC", "network": "base",
"issued_at": "2026-07-01T09:00:00Z", "expires_at": "2026-07-08T09:00:00Z"}]}
// payments.json
{"payments": [{"payment_id": "pay-001", "invoice_id": "inv-tool-calls",
"amount": 12.5, "asset": "USDC", "network": "base",
"tx_hash": "0xa1b2c3d4", "paid_at": "2026-07-03T10:15:00Z"}]}
// service_calls.json
{"service_calls": [{"call_id": "call-001", "invoice_id": "inv-tool-calls",
"status": "delivered", "completed_at": "2026-07-03T10:20:00Z"}]}
See CUSTOMIZE.md to wire your own billing tables, PSP webhooks or gateway logs
into these three shapes.
Test
bash smoke_test.sh # offline, no keys, no network
python test_agent.py
Files
| File | Role |
|---|---|
agent.py |
CLI + deterministic reconciliation core |
llm_adapter.py |
optional BYO-LLM CLI bridge |
PROMPTS.md |
versioned prompts, loaded by agent.py |
SPEC.md |
data contract and decision order |
CUSTOMIZE.md |
where to plug your own data in |
CHECKLIST.md |
operator runbook |
install.sh / *.service |
one-shot Debian install + systemd unit |
test_agent.py / smoke_test.sh |
offline test suite |
examples/ |
working input files |
Use a local model (Ollama)
The optional LLM step can also run on your own machine through Ollama, so no text leaves your server. No account and no API key.
ollama pull <model> # any chat model you like
export PACK_LLM=ollama # prefer Ollama even if a coding-agent CLI is installed
export PACK_OLLAMA_MODEL=<model> # optional: default is the first chat model the server lists
python3 -c "import llm_adapter; print(llm_adapter.detect())" # prints: ollama
PACK_OLLAMA_HOSTpoints to another server (defaulthttp://127.0.0.1:11434);OLLAMA_HOSTis also read whenPACK_LLM=ollama.PACK_OLLAMA_NUM_CTXsets the context size if your reports or prompts are long.- Without
PACK_LLM, a local Ollama server is used only when no Claude Code, Kimi Code or Codex CLI is found. - If the server is down or the model is missing, the pack keeps working in its deterministic mode.
Learning memory (local and private)
Every LLM call this pack makes is journaled in ./.pack_memory/ next to the code, so the pack can improve for you. Nothing is ever sent anywhere.
- What is kept: the task name, the masked input, the masked answer, status and latency. Keys, tokens, e-mails, phone numbers, IP addresses, card and IBAN numbers and personal paths are masked before writing; the system prompt is never stored.
- Teach it:
python3 pack_memory.py feedback last okconfirms a result,python3 pack_memory.py feedback last bad --fix "the right answer"corrects it. Confirmed and corrected runs are reused as validated examples when a similar input comes in. Unrated runs are never injected into a prompt. - Find similar cases:
python3 pack_memory.py similar "some text"andpython3 pack_memory.py clusters(groups of near-identical failures) work in plain Python, no model needed. Optional meaning-based matching uses an embedding model you already run locally:python3 pack_memory.py enable-semantic(Ollama on this machine only). - Share, only if you want:
python3 pack_memory.py exportwrites an anonymised summary (counts, statuses, size buckets, no text) for you to review. - Control:
PACK_MEMORY=0turns it off,PACK_MEMORY_STORE_TEXT=0keeps fingerprints only,python3 pack_memory.py purge --yeswipes it.
License
Personal use per purchase. Resale prohibited.
