LLM-assisted transaction ingestion for Beancount personal finance ledgers.
bean-sync fetches financial data from email alerts, bank websites, and receipt photos, then uses an LLM to categorise each transaction and write .bean sidecar files ready to include in your ledger.
Sources land in sources/ organised by provider and quarter:
sources/
├── _primary.bean ← auto-generated by bean-sync; includes primary sources only
├── inbox/2026-Q2/
│ ├── 2026-06-19_receipt_uid123.html ← downloaded email
│ └── 2026-06-19_receipt_uid123.bean ← LLM-generated sidecar
├── mycard/2026-Q2/
│ ├── 2026-06-19_Purchase_uid456.html
│ └── 2026-06-19_Purchase_uid456.bean
└── receipts/
├── 2026-06-19_grocery_run.jpg
└── 2026-06-19_grocery_run.bean
bean-sync ingest fetches new data for each source, then calls the LLM to generate .bean sidecars for any raw files not yet processed.
bean-sync parse re-processes existing raw files that have no .bean sidecar — useful after deleting sidecars to re-run the LLM with an updated model or prompt.
main.bean includes sources/_primary.bean, which bean-sync regenerates automatically on every ingest and config save. You never need to edit the include list by hand.
pip install beancountioOr with uv:
uv tool install beancountioA development shell is provided via flake.nix:
nix developThis installs all Python dependencies, beancount, beanquery, and Playwright browsers for browser-automated sources.
# Create a new ledger directory
mkdir my-ledger && cd my-ledger
bean-sync init
# Set your LLM API key (OpenRouter is the default provider)
export OPENROUTER_API_KEY=sk-or-...
# Store credentials for any email or bank sources
bean-sync secrets set gmail_app_password
# Edit config.yaml and main.bean to match your accounts, then:
bean-sync ingestconfig.yaml lists every source: plugin type, where files live, and a per-source LLM hint. See config.yaml.example for a fully annotated example.
main.bean is your beancount ledger. The * Accounts section is read by the LLM to guide transaction categorisation — keep it up to date. See main.bean.example.
| Plugin | Description |
|---|---|
email |
Fetch emails from specific sender addresses and parse each one as a transaction |
email-receipt |
Scan whole inbox; parse any email the LLM identifies as a receipt or invoice |
image |
Scan a directory for dropped receipt photos (JPG/PNG/WEBP) |
cua |
Browser-automated account scraping (Credit Union Atlantic) |
stagehand |
Browser-automated scraping via a custom Stagehand .ts script |
Each source has an enrichment flag:
enrichment: false(default) — the source is primary: its parsed.beanfiles are included inmain.beanand count toward your ledger balances. Use for any source that is the authoritative record of a transaction (bank alert emails, scraped statements, receipt photos).enrichment: true— the source is enrichment only: its.beanfiles are used as LLM context when parsing other sources (matched by date ±3 days and dollar amount), but are not included inmain.bean. Use for inbox receipt scanning, where the bank alert is the authoritative record and the receipt just clarifies the payee.
InboxSource defaults to enrichment: true. All other source types default to enrichment: false.
sources/_primary.bean is auto-generated by bean-sync on every ingest run and config save. Do not edit it by hand.
nullable: true(default) — the LLM may return null for files that contain no transaction (marketing emails, payment confirmations, etc.). A null result writes an empty sidecar so the file is not reprocessed.nullable: false— if the LLM returns null, it is retried up to 3 times with an explicit instruction to produce a transaction. If it still returns null, an error is raised. Use for authoritative bank sources where every fetched file must appear in the ledger.
Each source has a hint — a plain-English prompt injected into the LLM system message. Include:
- The institution name and what kind of emails/pages to expect
- Which beancount account to use for the liability or asset side
- When to return null (e.g. "ignore payment confirmations")
Be specific. The more context the LLM has, the better the account classification.
- Enable IMAP — Gmail → Settings → See all settings → Forwarding and POP/IMAP
- Create an App Password — myaccount.google.com/apppasswords (requires 2-Step Verification)
- Store the credential:
bean-sync secrets set gmail_app_password - Reference it in config.yaml:
imap_password: !secret gmail_app_password
Credentials are stored in the system keyring (KDE Wallet / GNOME Keyring / macOS Keychain) and never written to disk in plain text.
The cua and stagehand plugins drive a real browser to log into your bank's website and download transactions. Store login credentials as secrets:
bean-sync secrets set cua_username
bean-sync secrets set cua_passwordRun with --headed the first time to watch the browser and catch any login or 2FA prompts:
bean-sync ingest --headed cuabean-sync init [DIRECTORY] scaffold a new ledger directory
bean-sync ingest [NAMES...] fetch new data and parse it for all (or named) sources
bean-sync parse [NAMES...] re-parse existing raw files without fetching
bean-sync serve launch the web UI
bean-sync secrets list list stored secret names
bean-sync secrets set NAME store a secret in the system keyring
bean-sync secrets delete NAME remove a secret
bean-sync ingest --since 2026-06-01 # fetch data from this date onwards
bean-sync ingest --headed # show the browser window (cua/stagehand only)
bean-sync ingest mycard inbox # fetch only named sourcesThe LLM accumulates merchant notes in .llm_notes.json. Keys are regex patterns matched against source text; values describe the merchant and which account to use. The LLM can add, update, and delete notes via tool calls during parsing — you never need to edit this file by hand.
bean-sync serveOpens a web interface at http://127.0.0.1:8080 with a dashboard (Sankey money-flow chart, transaction table), an ingest runner with live terminal output, a config editor, and a merchant notes editor.
| Variable | Purpose |
|---|---|
OPENROUTER_API_KEY |
Required for LLM parsing (default provider: OpenRouter) |
TAVILY_API_KEY |
Optional — enables web search for unknown merchants |