{"id":"f6a8daab-da36-4c23-b308-f026c8227b59","slug":"canhoto","kind":"mcp","name":"canhoto","summary":"Local-first personal finance engine for statement parsing, safe agent workflows, and monthly PDF summaries.","owner":"luabagg","repoUrl":"https://github.com/luabagg/canhoto","license":"MIT","licenseTier":"permissive","trust":"community","stars":2,"match":null,"fewStars":true,"format":"mcp","createdAt":"2026-08-13T01:19:49.000Z","addedAt":"2026-10-21T04:56:56.673Z","logoUrl":"https://avatars.githubusercontent.com/u/56180440?u=d3aa49c33e77c433fef4f69b64549c3174ccbaaf&v=4","linkOnly":false,"collection":null,"skillPath":null,"categories":["files-documents","data-analytics","databases"],"tags":["cli","expense-tracker","personal-finance","statement-parser","expense-tracking","pdf","privacy","python"],"runtime":"python","local":true,"cloudCapable":false,"cloudCapability":"unknown","cloudReadyOnPlatform":false,"needsApiKey":false,"reviewed":false,"syncedAt":"2026-10-26T13:21:23.858Z","sample":false,"url":"https://conduits.dev/tools/canhoto","language":"Python","transports":["stdio"],"install":{"pypi":"canhoto"},"requirements":{"env":[],"upstreamMayCharge":false},"tools":null,"readme":"# Canhoto\n\nCanhoto keeps your bank and card statements on your computer. It parses\nstatements, categorizes spending, asks you about uncertain items, and exports a\nmonthly PDF summary.\n\n> **Using an AI agent?** Connect `canhoto-mcp`. The agent gets a bounded set of\n> tools to parse statements, review and categorize transactions, read monthly\n> totals, and export reports. It never gets SQL access or a full ledger dump.\n> The CLI gives you the same workflow by hand.\n\n| | |\n|---|---|\n| Package | `canhoto` |\n| CLI | `canhoto` |\n| MCP | `canhoto-mcp` |\n| Data | `~/.canhoto` or `$CANHOTO_DATA_DIR` |\n\n## Install\n\nInstall the CLI and the MCP server from PyPI:\n\n```bash\nuv tool install canhoto\n```\n\nOr install from a checkout of this repository:\n\n```bash\nuv tool install .\n```\n\nOr run it in place:\n\n```bash\nuv sync\nuv run canhoto --help\n```\n\nThen create the data directory and check it:\n\n```bash\ncanhoto init\ncanhoto doctor\n```\n\n## How it works\n\n### Parser setup\n\n[Create a parser](#create-a-parser) for each statement format. Reuse it each\nmonth; test it again when you change its code.\n\n```mermaid\nflowchart LR\n  Preview[\"Preview a sample statement\"] --> Write[\"Write the parser\"]\n  Write --> Test[\"Test it on the sample\"]\n  Test --> Enable[\"Enable after a successful test\"]\n```\n\nA parser reads statement text and returns transaction rows. `ingest` extracts\nthe text and runs an enabled parser. You do not run the parser separately.\n\n### Monthly workflow\n\n```mermaid\nflowchart TD\n  Files[\"Statement files: PDF or text\"] --> Ingest[\"1. Ingest using an enabled parser\"]\n  Ingest --> Ledger[(\"Local SQLite ledger\")]\n  Manual[\"Optional: add manual rows through the CLI\"] --> Ledger\n  Ledger --> Rules[\"2. Categorize the month\"]\n  Rules --> ReviewChoice{\"Review pending items?\"}\n  ReviewChoice -->|Yes| Review[\"3. Review a batch\"]\n  Review --> Apply[\"Apply your category decisions\"]\n  Apply --> ReviewChoice\n  ReviewChoice -->|\"No, or finished\"| Reports[\"4. Choose a monthly summary\"]\n  Reports --> Totals[\"Read totals: breakdown\"]\n  Reports --> PDF[\"Save a PDF: export pdf\"]\n```\n\n- Ingest, rules, and category decisions update the same local ledger.\n- Categorization applies your rules, built-in rules, self-transfer checks, then merchant memory.\n- Review returns batches, not the full ledger. Repeat it until you finish your decisions.\n- You can request totals or export a PDF without review. Unreviewed expenses can remain uncategorized.\n- Breakdown and PDF are separate outputs. Use either one, or both.\n- Manual rows join the same reports. They do not require a parser or a statement.\n\n## Currency and country support\n\nManual entries and parser rows support ISO 4217 currencies with defined decimal\nminor units. JPY uses zero decimal places; KWD uses three. Input validation\nrejects rounding, non-finite amounts, and integer overflow.\n\n| Part | Current behavior |\n|---|---|\n| [Parser interface and ledger](src/canhoto/core/models.py) | Each row stores its currency and decimal exponent. Non-two-decimal parser rows must declare the exponent explicitly. |\n| [Parser row and review defaults](src/canhoto/core/redaction.py) | BRL is the default when a parser omits currency. Review preserves an explicit currency. |\n| [Manual transactions](src/canhoto/core/manual.py) | `--currency` overrides the configured default. Each row stores its currency and includes it in manual output. |\n| [Monthly totals](src/canhoto/core/breakdown.py) | Income, expenses, net, and categories are grouped by currency. No total combines currencies. |\n| [PDF summaries](src/canhoto/exporters/pdf_summary.py) | Each currency starts its own page. All profiles use explicit codes, such as `JPY 1,250` and `KWD 12.345`. |\n| [Automatic categorization](src/canhoto/core/categorize.py) | Built-in matches include Portuguese card-payment, savings, and income terms. Self-transfer checks recognize Pix, TED, and DOC. Merchant cleanup also recognizes Brazilian statement text. |\n| [Merchant memory](src/canhoto/core/categorize.py) | Person-ID checks include a CPF-sized digit heuristic. This is not a complete identity detector. |\n\nA ledger can contain multiple currencies. `breakdown` returns money under\n`breakdown.by_currency`, even when only one currency exists:\n\n```json\n{\n  \"JPY\": {\"income\": \"3000\", \"expenses\": \"1250\", \"net\": \"1750\"},\n  \"KWD\": {\"income\": \"0.000\", \"expenses\": \"12.345\", \"net\": \"-12.345\"}\n}\n```\n\nThese are separate balances, not values to add together. Canhoto does not\nfetch exchange rates or convert currencies. Reports refuse months above the\n50,000-row limit instead of showing partial totals.\n\nUpgrade preserves existing amounts and running balances as hundredths. It\ndoes not infer a new scale from their currency codes. Legacy fractional JPY\nvalues remain unchanged and appear without rounding. Backup restore preserves\nthese recorded units too.\n\n## Configuration\n\nSet a user-wide default, or override it for the active ledger:\n\n```bash\ncanhoto config set --global currency EUR\ncanhoto config set currency USD\ncanhoto config get currency\ncanhoto config get --global currency\ncanhoto config unset currency\n```\n\n- Global settings use `$XDG_CONFIG_HOME/canhoto/config.json`, or `~/.config/canhoto/config.json` when XDG is unset or relative.\n- Ledger settings use the existing `config.json` in the active data directory.\n- Local means the data directory selected by `CANHOTO_DATA_DIR`, not your current working directory.\n\nFor a new manual row, currency precedence is:\n\n1. The explicit `--currency` option.\n2. The ledger's currency override.\n3. The global currency default.\n4. BRL when neither config declares a currency.\n\n`config get currency` returns the effective value as JSON. With `--global`,\nit returns only the declared global value, or `null` when none exists. Reads\ndo not create files or a ledger. `config unset currency` restores global\ninheritance. Add `--global` to remove the user-wide default instead.\n\nThe only supported key is `currency`. Codes need three ASCII letters and\nare normalized to uppercase. New settings must identify an ISO currency with\ndefined decimal minor units. Currency data comes from the `iso4217` package.\n\nChanging config does not change stored transactions. Ledger backups include\nlocal overrides, but not global preferences. Config management is CLI only.\n\n## Create a parser\n\nCanhoto does not ship bank-specific parsers. A parser is one small Python file\nin `~/.canhoto/parsers/`. It reads the text of a statement and returns its rows.\nYou do not need this repository to write one.\n\n1. Create the parser file:\n\n   ```bash\n   canhoto parsers scaffold --id my_bank_card --type card --institution my_bank\n   ```\n\n2. See the text the parser receives. This is the extracted text, not the PDF\n   layout:\n\n   ```bash\n   canhoto parsers preview --file ~/statements/sample.pdf > sample.txt\n   ```\n\n3. Edit `~/.canhoto/parsers/my_bank_card.py`. The file already reads rows such\n   as `2026-06-02  ACME STORE  -12.34`, and its comments list the rules.\n   Change three things:\n   - `sniff()`: return a score above 0 only for text unique to this statement.\n   - `_ROW` and `parse()`: match the row format you saw in step 2.\n   - `_CURRENCY`: your ISO 4217 currency code.\n\n4. Test the parser, then enable it:\n\n   ```bash\n   canhoto parsers test --id my_bank_card --file ~/statements/sample.pdf\n   canhoto parsers enable --id my_bank_card\n   ```\n\nThe test passes only when `parse()` returns at least one transaction and\n`sniff()` claims the sample. A failed test disables the parser until it passes\nagain.\n\nUse the shared money helpers. Do not multiply every amount by 100:\n\n```python\nfrom datetime import date\nfrom canhoto.core.models import LedgerTransaction\nfrom canhoto.core.money import currency_exponent, to_minor\n\ncurrency = \"KWD\"\ntx = LedgerTransaction(\n    id=\"my_bank_card-op0001\",\n    date=date(2026, 6, 2),\n    amount_minor=to_minor(\"-12.345\", currency),\n    amount_exponent=currency_exponent(currency),\n    currency=currency,\n    source_kind=\"card\",\n    month=\"2026-06\",\n)\n```\n\nNon-two-decimal parser rows must declare the exponent explicitly. Existing\nnon-two-decimal parsers need an update and a new test before use. Retain their\ntransaction ids when updating units; otherwise re-ingest can create duplicates.\n\nKeep transaction ids stable. Include account and currency when bank operation\nids are not unique across feeds. Never use the row position. Leave `category`\nand `kind` empty. Your rules and review set them.\n\nFor password-protected PDFs, pass `--pdf-password` or set\n`CANHOTO_PDF_PASSWORD`. For a complete example, see\n[`examples/parsers/`](https://github.com/luabagg/canhoto/tree/main/examples/parsers).\nAn agent connected to `canhoto-mcp` can also write the parser for you. See\nthe MCP section below.\n\n## Process a month\n\n```bash\n# Read statements into the ledger.\ncanhoto ingest ~/statements/*.pdf\n\n# Apply your rules, the built-in rules, and merchant memory.\ncanhoto categorize rules --month 2026-06\n\n# List the items that still need a decision.\ncanhoto review --month 2026-06\n\n# Apply your decisions.\ncanhoto categorize apply --file patches.json\n\n# See income, expenses, and category totals.\ncanhoto breakdown --month 2026-06\n\n# Write the PDF summary.\ncanhoto export pdf 2026-06\n```\n\n`patches.json` is a list of changes, one per transaction id from `review`:\n\n```json\n[\n  { \"id\": \"my_bank_card-2026-06-02-0001\", \"category\": \"Groceries\",\n    \"kind\": \"expense\", \"is_expense\": true, \"needs_review\": false }\n]\n```\n\nCanhoto marks every row you change this way as `manual`. No automatic rule\nchanges a manual row again.\n\nThe PDF goes to `~/.canhoto/exports/2026-06-summary.pdf` by default. It shows\nseparate currency totals, category totals, and top merchants in each category.\nIt never contains a transaction list or raw statement descriptions.\n\n### Keep manual transactions\n\nSome money moves outside every statement you can export, for example a Pix sent\nstraight from a savings reserve. Keep those rows yourself:\n\n```bash\ncanhoto manual add --date 2026-09-17 --amount -3080.00 \\\n  --description \"Pix enviado Example Tax Office\" --category Taxes --currency BRL \\\n  --merchant IPVA --account cofrinho --note \"Car tax, paid from the reserve\"\ncanhoto manual list [--month 2026-09]\ncanhoto manual edit --id manual-... --amount -3100.00 --category Taxes\ncanhoto manual remove --id manual-...\n```\n\nA negative amount is money out. With `--category`, the row is set by hand.\nWithout it, your rules classify the row, or it waits in `review`. Canhoto\nrefuses to add the same date, currency, amount, description, and account twice.\nAn edit keeps the id. `edit` and `remove` refuse statement rows: those change\nonly when you ingest the statement again.\n\nUse `--currency EUR` on add to override the configured default. Add, edit,\nand list output include each row's currency. An edit without `--currency`\nkeeps the stored code, even after config changes. Currency correction preserves\nthe numeric major-unit amount without FX conversion. It rejects a correction\nthat would need rounding, such as EUR 12.34 to JPY.\n\n### Teach Canhoto your rules\n\nWhen a counterparty always means the same thing, store a rule. `categorize\nrules` applies it to every future statement.\n\n```bash\ncanhoto rules add --pattern \"PIX RECEBIDO ACME LTDA\" --direction in \\\n  --min 1200 --max 1300 --currency BRL --category Income --kind income \\\n  --note \"Monthly pay from my company\"\ncanhoto rules list\ncanhoto rules remove --id 3\n```\n\n| Option | Meaning |\n|---|---|\n| `--pattern` | Regular expression, case-insensitive, matched against the description. |\n| `--direction` | `in` (money received), `out` (money sent), or `any`. |\n| `--min`, `--max` | Amount range, inclusive, without the sign. Both use the rule's recorded currency and precision. |\n| `--currency` | Match only this currency. Bounded rules capture the configured currency when omitted. Unbounded rules without currency can match all currencies. |\n| `--source-kind` | Match only `account` or `card` rows. |\n| `--category` | The category to set. |\n| `--kind` | `expense`, `income`, `transfer`, `internal_transfer`, `self_transfer`, or `card_payment`. Transfers do not count as income or spending. |\n| `--review` | Set the category, but keep the row in the review queue. Use this when an amount range cannot separate two cases. |\n| `--note` | Why the rule exists. Agents read it during review. Do not put secrets in it. |\n| `--priority` | Lower numbers run first. The first matching rule wins. |\n\nYour rules run before the built-in rules. They never change manual rows. When\nyou upgrade to the version with rules, Canhoto marks your existing reviewed\nrows as manual.\n\nLegacy amount-bounded rules have no recorded currency. `rules list` shows\n`currency: null` for these rules. Remove and recreate them with an explicit\n`--currency` before running rules. Canhoto preserves their amounts and notes;\nit does not guess their denomination.\n\nTo remember one merchant without a full rule, use merchant memory:\n\n```bash\ncanhoto categorize merchant --key CURSOR --category Subscriptions\n```\n\n### PDF profiles\n\nChoose a built-in style:\n\n```bash\ncanhoto export pdf 2026-06 --profile canhoto\ncanhoto export pdf 2026-06 --profile modern --output ~/Documents/2026-06.pdf\ncanhoto export pdf 2026-06 --profile minimal\n```\n\n- `canhoto`: receipt-style report with a category chart.\n- `modern`: metric cards and a category chart. Very long amounts use full-width metric rows.\n- `minimal`: text-only report without a chart.\n\n## Back up and restore\n\nWrite your ledger, rules, merchant memory, config, and parsers to one file:\n\n```bash\ncanhoto backup                        # ./canhoto-YYYY-MM-DD.canhoto\ncanhoto backup --output ~/backups/home.canhoto\n```\n\nRestore it into a new or empty data directory. Do not run `canhoto init` there first:\n\n```bash\nCANHOTO_DATA_DIR=~/.canhoto-new canhoto restore ~/backups/home.canhoto\n```\n\nA `.canhoto` file is a zip archive. Open it with any unzip tool:\n\n| File | Content |\n|---|---|\n| `manifest.json` | Format version, Canhoto version, schema revision, row counts, SHA-256 of each file |\n| `canhoto.sql` | Plain SQL dump of `canhoto.db`: schema and rows. `sqlite3 new.db < canhoto.sql` rebuilds it. |\n| `config.json` | Settings and the parser registry |\n| `parsers/*.py` | Your parser modules |\n\nThe backup does not include raw statements. A restored ledger cannot re-parse\nold statements until you ingest them again.\n\nRestore checks every checksum and the schema revision before it writes. It\nprepares and migrates the ledger in a private temporary directory. It installs\nthe full restore only after all files are ready. A failed restore permits a\nretry. Restore refuses non-empty directories and directory links. It keeps\nparser modules in the restored directory's `parsers/` folder, even when the\nbackup used a custom parser location.\n\nRestore only backups you trust. Checksums do not authenticate the sender. The\nbackup includes executable parser modules and holds your full ledger in plain\ntext, so keep the file private. `backup` and `restore` are CLI only: the MCP\nserver never gives an agent a full ledger dump.\n\n## MCP\n\nThe CLI and the MCP server use the same service layer. For agent-assisted use,\nstart the MCP server. The agent follows this flow:\n\n`statement_preview` -> `parser_*` -> `ingest` -> `rule_list` -> `run_rules` ->\n`review_batch` -> `set_categories` -> `month_breakdown` -> `export_pdf`\n\nWhen you explain a recurring counterparty to the agent, it can store it with\n`rule_add`. The MCP server exposes only domain tools. It does not give SQL\naccess or full ledger dumps.\n\nTo let an agent write parsers, add this to `~/.canhoto/config.json`:\n\n```json\n{ \"agent_view\": { \"allow_parser_writes\": true } }\n```\n\nExample MCP host configuration:\n\n```yaml\nmcp_servers:\n  canhoto:\n    command: canhoto-mcp\n```\n\n## Dashboard\n\nThe page and the API are separate programs. The page only sends HTTP requests.\nThe API calls the same service as the CLI. The MCP server is a third program.\n\n```bash\ncanhoto dashboard\n```\n\nOpen `http://127.0.0.1:8766`. The API listens on `http://127.0.0.1:8765`.\nBoth bind to loopback only.\n\nYou can start them apart:\n\n```bash\ncanhoto-api\ncanhoto-page\n```\n\nThe page can upload a statement, run rules, review a batch, keep manual rows,\nread a month, and download the summary PDF. It does not write parsers. Backup\nand restore stay on the CLI.\n\nAn upload is stored in `~/.canhoto/inbox/`, then ingested. Ingest still keeps\na content-addressed copy in `raw/`. Backup still omits statement files.\n\n## CLI commands\n\n```text\ncanhoto init | doctor\ncanhoto config get|unset [--global] currency\ncanhoto config set [--global] currency CODE\ncanhoto parsers scaffold|preview|test|enable|list\ncanhoto ingest <files...> [--pdf-password PASSWORD]\ncanhoto categorize rules --month YYYY-MM\ncanhoto categorize apply --file patches.json\ncanhoto categorize merchant --key KEY --category CAT\ncanhoto rules add|list|remove\ncanhoto review --month YYYY-MM [--cursor ID] [--limit N]\ncanhoto breakdown --month YYYY-MM\ncanhoto export pdf YYYY-MM [--profile canhoto|modern|minimal] [--output PATH]\ncanhoto manual add --date YYYY-MM-DD --amount SIGNED --description TEXT [--currency CODE] [--category CAT]\ncanhoto manual list [--month YYYY-MM] | edit --id ID [fields] | remove --id ID\ncanhoto backup [--output PATH]\ncanhoto restore PATH\ncanhoto dashboard [--api-port 8765] [--page-port 8766]\n```\n\n## Develop\n\n```bash\nuv sync --extra dev\nuv run pytest -q\nuv run ruff check src/canhoto tests\nuv run mypy -p canhoto\n```\n\nOr use the [`justfile`](https://github.com/luabagg/canhoto/blob/main/justfile) with [Just](https://just.systems/).\n\n### Release\n\nPushing a version tag publishes to PyPI through\n[`.github/workflows/release.yml`](https://github.com/luabagg/canhoto/blob/main/.github/workflows/release.yml). The workflow\ntests, builds, smoke-tests the wheel and the source distribution, and publishes\nwith PyPI Trusted Publishing. The tag must match the version in\n`pyproject.toml`.\n\n```bash\nuv version --bump patch   # or minor / major\ngit commit -am \"chore: release v$(uv version --short)\"\ngit tag -a \"v$(uv version --short)\" -m \"v$(uv version --short)\"\ngit push origin main --tags\n```\n\n## Privacy\n\nYour statements and database stay in the data directory. Do not commit\nstatements, tokens, database files, backups, or `~/.canhoto`. Run\n`canhoto backup` before you upgrade: the database holds your ledger and your\nrules.\n"}