Python/FastAPI service that processes Gmail, classifies purchases and notifications, and feeds structured data into Current OS and other services. Deployed on Taproot via Docker.
PROJECT
Bud
Inbox intelligence
PythonFastAPIDockern8nSupabase
ABOUT
FEATURES
SHIPPED
- ✓PurchaseHandlerMAY 2026
Classifies Gmail purchase receipts and extracts structured data — merchant, amount, items. Deployed on Taproot via Docker.
- ✓Finance DashboardOCT 2026
Home, Plan, Ledger and Radar at bud.rootstack.dev: the month at a glance, what a normal month costs, why each category changed, and what is coming due. Built on the Firefly III ledger with Bud's own categorization layer.
IN PROGRESS
- ◎Gmail Classification
n8n workflow connects Gmail OAuth to Bud's classification endpoint. Routes notifications, receipts, and digests to appropriate handlers.
PLANNED
- ○Inbox Intelligence
Surfaces patterns across inbox data — recurring charges, unusual spend, subscription changes. Feeds summary into Current OS.
CHANGELOG
BUDfeatureinfrastructurephase
Finance dashboard live — Ledger, Radar, Home, and Plan at bud.rootstack.dev
Features
- Finance Steps 6–9 built, deployed, and merged — Home, Plan, Ledger, and Radar serve from
https://bud.rootstack.devbehind Cloudflare Access - Ledger ("Why Column"): monthly variance per category with the drivers behind each change, and category corrections made from the page
- Merchant rules are data — the
merchant_rulestable replaces the hardcoded list, and a tick-box on any ledger row creates a rule - Radar: a recurrence engine forecasts expected bills and pay, flags late payments with a reason, and lists subscriptions with a discretionary total
- Balances are real — opening balances set from SimpleFIN's figures put all nine bank accounts on the bank's number to the cent
- Chase history backfilled — 1,232 transactions from 42 statement PDFs (Dec 2024 to Sep 2026), every statement reconciled to its own printed totals
- Home: the month at a glance — spending against "usual so far" (the day-by-day median of up to six earlier months), measured through the bank's last reported day
- Plan: what a normal month costs and what is left — typical income minus typical spending minus money set aside for quarterly and annual bills
- Budgets are effective-dated — the budget for a month is the latest row at or before it, so a change never rewrites history
- Checking line on Home and Radar — balance, usual balance for the day of the month, and the lowest projected point before payday
- Discord alerts fire on change only — a newly missed payment, an obligation entering its warning window, or checking projected under the floor
Bug Fixes
- bud-app's Vercel
api/routes removed —/api/purchaseswas reachable with no authentication - A payment landing before its usual day no longer makes the same month's usual day the next expected date
- A third-paycheck month no longer skews the typical month — pay is identified by cadence and size, and extra-pay months are left out
Infrastructure
- Twelve Uptime Kuma monitors share one Discord notification — radar health, data freshness, the daily import job, the dashboard, n8n, and Firefly
- Migrations 008–016 applied to CT 104 —
supersede_enrichment(),merchant_rules, obligations and series settings,budgets,alert_settings - n8n prunes executions older than 7 days, and its database was compacted with
VACUUM INTOafter CT 102's root disk filled (grown 7.8 GB to 12 GB) Bud — Gmail Purchase Routerunpublished — zero successes in 839 runs; the Ollama Catch-up Sweep is the real ingestion path- Gmail OAuth credential re-authorized after expiring 2026-10-09 with
invalid_grant - The
web/review-queue app retired and removed from the repo
Lessons
- An import job can fail and still log success — the importer could not resolve SimpleFIN's hostname,
/autoimportanswered "Seems to have worked!", and onlydocker logsshowed the error - Without an opening balance a Firefly total is only net movement since the feed began — every balance was wrong until the opening figures were set
- Measuring "so far" through today makes every month look underspent — the bank feed trails by about two days, cards by two to three
- The median survives a mortgage paid twice; the mean does not — and category medians do not sum to the total's median
- Statement descriptors carry a city and state the live feed lacks — untrimmed, every merchant looks new the month the feed takes over
- A new Kuma monitor's "Default" notification badge does not tick the toggle — each monitor needs it ticked by hand
- A session's transcript can be missing from disk when it closes —
/resumeand Switchboard had nothing to restore, and a plan one minute into drafting was lost
TODO
- Redraft the Bud next-steps plan — post-Step-9 action items, the documented wishlist, and the Radar/Ledger phone layout
- Write merchant rules for the ~550
uncategorizedrows from the Chase history import - Delete the Vercel project
budby hand — it still holds the old Supabase settings - Remove the cloud
service_rolekey and Kroger secret from n8n's compose file - Push
docs/notes-2026-10-12and open its PR — two commits are local only - Find out why session transcripts are not staying on disk
BUDfeatureinfrastructurebug
Chase connected — card-aware categorizer, 451/451 transactions enriched, repos backed up
Features
- Chase Freedom Unlimited (0706) and Flex (7501) linked through SimpleFIN and imported as Firefly Debt liabilities 75 and 76 — the ~59% of outflow that only appeared as lump
CHASE CRDpayments now has line-item detail - Opening balances derived from the September statements and cross-checked two ways (statement roll-forward and live balance) — both cards land on Chase's balance to the cent once in-transit charges post
- Categorizer is now credit-card aware — counterparty prefers the Expense/Revenue account, and an
on_liabilityflag routes card payments, returned payments, and opening balances totransfer - 451/451 Firefly transactions categorized — 161 new rows, merchant rules user-approved, one manual override (a $150 Zelle that was an Airbnb, filed as
travel) - "Invisible" spend (paid to unconnected cards) drops from $17,074 to $82.30 — only a single Comenity store-card payment still hides its detail
simplefin_sync_accounts.pymaps accounts by unique ID prefix, repeatable in one request, all-or-nothing, with a config backup before every writesimplefin_backfill_once.shruns one wider import window and restores the 30-day config through anEXITtrap, even on failure
Bug Fixes
- Card purchases had the card's own name as their merchant — the ledger query took
max()over every non-asset account, and a card journal has two, so the card won whenever it sorted after the merchant - The 2026-09-08 returned autopay would have counted as $2,347.40 of spend — now classified as a transfer that reverses a payment
- Sync script printed account IDs truncated to 24 characters, unusable as
--mapinput, and accepted any string as a mapping (including the bare prefixACT-) .env.examplewas never committed — the.env.*ignore rule swallowed the template along with real env files- Maintenance scripts vanished from the container on rebuild — they had been
docker cp'd in; now baked into the image withCOPY scripts/
Infrastructure
- A Supabase
service_roleJWT found hardcoded in an n8n workflow export during a pre-push history scan — scrubbed from all 16 commits withgit filter-repo --replace-textbefore the first push, verified absent from every object - Private GitHub remotes created for
budandbud-app— a month of finance work no longer lives on one disk .gitattributesforces LF for*.sh—core.autocrlf=truewould otherwise hand CT 100 a script bash can't parsetests/test_categorizer.pyadded — 35 cases from real ledger descriptors, the first tests in the repo- Liability start amounts set through
PUT /api/v1/accounts/{id}— the Firefly v6.6.6 edit form reported success while writing nothing
Lessons
- A newly linked SimpleFIN account carries ~30 days of history, not 90 — a 90-day import window returned only duplicates of what the first pull already had
- A "Success" banner is not evidence of a write — Firefly accepted the edit form, dropped the start amount, and only a direct
account_metaquery showed nothing changed - Statement balances beat live balances for an opening-balance anchor — the live figure already included charges still in transit and would have double-counted them once they posted
- Rules written before a data shape exists fail silently on it — the categorizer assumed every non-asset account was a merchant until a card made that false
- Scan full history, not HEAD, before a first push — the leaked key sat in a July commit and in the current tree, and an unpushed repo is the only point where scrubbing beats rotating
- A bounced autopay costs more than a fee — losing the grace period turned a returned payment into $74.14 of interest
TODO
- bud-app
api/routes have no caller auth — must verify the Supabase JWT before any deploy - Delete or repoint the n8n "Gmail historical backfill" workflow that still hardcodes the cloud
service_rolekey - Move merchant rules to a Taproot
merchant_rulestable once the Step 6 dashboard has an edit UI - Rebuild the Step 6 dashboard plan — the finance plan and vision brief are both missing from disk
BUDfeatureinfrastructurebug
Catch-up Sweep live — watermark-based Gmail coverage, backfill gap confirmed closed
Features
- Catch-up Sweep workflow published to n8n (ID: NXwPAwurVz56Mfdx) — runs every 30 minutes, fetches up to 25 emails from Gmail starting 1h before the watermark in
email_log - Watermark logic: queries
MAX(received_at)fromemail_log, subtracts 1h safety buffer, buildsafter:YYYY/MM/DDGmail filter — incremental by design, idempotent on re-run - Ollama classification (qwen2.5:3b) wired into the sweep — same Parse Classification logic as the main pipeline, handles code fences and
"null"string normalization - Historical backfill workflow ported to Ollama (
gmail-historical-backfill.jsonv3) — covers Apr 17–May 16 gap period, manual-trigger, limit 50
Bug Fixes
Prefer: resolution=ignore-duplicatesheader alone returned 409 on duplicate inserts — PostgREST requires?on_conflict=message_idas a URL query param in addition to the Prefer header; added to both sweep and backfill Log Unclassified nodes
Infrastructure
- n8n CLI workflow import:
docker cp file.json n8n:/tmp/ && docker exec n8n n8n import:workflow --input=/tmp/file.json— usable when n8n API key is unavailable or the workflow was never registered - Bud CLAUDE.md updated: Gmail OAuth re-auth note added (SSH tunnel pattern:
ssh -L 5678:localhost:5678 root@192.168.1.152), Steps 6-7 marked complete - Historical gap validated via direct Supabase row count: 78 rows in
email_logfor Apr 24–May 16; first backfill batch (50 emails) returned all 409 duplicates, confirming the Jul 5 work had already covered the period
Lessons
- PostgREST
resolution=ignore-duplicatesneeds two things: thePreferheader AND?on_conflict=<column>in the URL — the header alone silently returns 409 and the error looks identical to a real constraint violation - Expired Gmail OAuth in n8n manifests as
ETIMEDOUTon a Google IP — looks like a network failure, not a credential error; check n8n Credentials before debugging connectivity - A gap in
email_logdoesn't mean a gap in coverage — the Jul 5 backfill had already filled Apr 24–May 16; first backfill run hitting 409 on all 50 rows was the confirmation signal, not a failure
BUDinfrastructureaibug
Ollama migration complete — 24 dropped purchases recovered, pipeline validated
Features
- Extraction backend swapped from Claude Haiku to local Ollama (
qwen2.5:3bon CT 100:11434) — zero API costs, inference stays on the homelab - All 24 purchase emails dropped during the API credit outage recovered — 39 active purchases now in Supabase, zero errors remaining
_fix_null_strings()added toollama_client.py— walks the parsed JSON dict before Pydantic validation to replace"null"strings with PythonNone- HTML fallback in n8n "Build Handler Payload": when plain-text body is under 500 characters, strips and uses HTML body instead — small models extract nothing useful from 50-word plain-text bodies
Bug Fixes
- n8n "Build Handler Payload" read
email.payload.headers(raw Gmail API format) — n8n's Gmail node returns a parsed object with top-level fields (from.value[0],subject,text); rebuilt JS to match the actual output shape - n8n HTTP Request timeout (300s) fired before Ollama processed deep-queue items — 24 concurrent requests queue sequentially; item 24 waits 23×67s ≈ 25 min before Ollama starts it; raised to 1800s in n8n and httpx
docker restartwas used after code edits — command reuses the baked image and silently runs old code; switched todocker compose up --build -d
Infrastructure
src/services/ollama_client.py— new extraction service using httpx against Ollama's/v1/chat/completionsendpoint; 1800s timeout; JSON fence stripping; null-string normalizationsrc/config.pyupdated:anthropic_api_keynow optional (defaults to"");ollama_urlandollama_modeladded- Migrations 003 (unsubscribe support) and 004 (
amountnullable on purchases) applied scripts/find_dupes.py— one-shot soft-delete deduplication script; idempotent; dry-run flagworkflows/backfill/— n8n recovery workflow JSON export and Python backfill runner
Lessons
docker restartreuses the baked image — Python file changes are invisible untildocker compose up --build -d; the container logs look healthy the whole time- n8n manual-trigger workflows cannot be triggered via REST API;
POST /api/v1/workflows/{id}/runreturns "POST method not allowed"; the UI Execute button is the only path - Ollama queues inferences sequentially — n8n's HTTP timeout counts from when the request is sent, not when Ollama starts it; large concurrent batches will always exceed any reasonable n8n timeout
- Small models (
qwen2.5:3b) emit"field": "null"as a string, not JSONnull— Pydantic's date and float parsers reject it; the fix is a pre-validation dict walk, not a prompt change
TODO
- Bud Web UI — Step 1 scaffold at
bud.understorylabs.co(Split Brain: Vercel reads, Taproot acts); plan at~/.claude/plans/bud-web-ui-plan.md
BUDfeaturebuginfrastructure
Pipeline live — Gmail trigger activated, handler resilience fixed
Features
- Gmail purchase router workflow published and active in n8n — polls inbox every minute for new emails
- Classification pipeline end-to-end: Gmail trigger → Claude Haiku classify → confidence-based routing → handler POST or Supabase log
- Read status filter set to all emails (read + unread) — prevents missed classification if email is opened before n8n polls
Bug Fixes
- Handler crashed on emails where Claude returned null for amount —
PurchaseExtraction.amountwas required, now optional email_logaudit trail never written when extraction failed — crash happened inextract()beforestore()was reached; moved email_log write before compliance checks- Handler returned 200 with error body but route still reported
status: "ok"— now returns"partial"when email is logged but no purchase row created - Deployed model mismatch:
EmailPayloadon docker-host was missingunsubscribe_linkandis_readfields — scp'd the updated model file
Infrastructure
- Lost session reconstructed from git state and uncommitted files — committed as
dab51e7 - Google Cloud OAuth, n8n Gmail credential, and workflow import confirmed already complete from prior session
- Gmail Trigger node had stale "Every Day" poll entries from JSON import — removed, set to Every Minute
- Deployment confirmed: scp to
/opt/bud/on docker-host,docker compose build && up -d
Lessons
- When deploying via scp, every changed file must be pushed — not just the files edited in the current session; a model file mismatch caused an AttributeError that only surfaced at runtime
- n8n workflow JSON imports can carry orphaned poll configuration entries that prevent activation — the
.trim()error gave no indication which node or field was the problem - Handler pipelines should write the audit log (email_log) unconditionally at the top of
store(), not after validation — a crash inextract()leaves zero trace otherwise
TODO
- Confirm end-to-end with a real purchase email landing in both
email_logandpurchasestables — fix deployed but not yet validated
BUDinfrastructureaifeature
PurchaseHandler live — Steps 2–4 complete, n8n up
Features
- PurchaseHandler local test passed — 3/3 email formats (Amazon, DoorDash, Netflix) extracted and written to Supabase
- Handler deployed on docker-host — FastAPI container running at
192.168.1.153:8001, health endpoint confirmed from network - n8n operational — CT 102 (
192.168.1.152:5678) created, Docker installed, n8n running and survives reboot - Gmail API enabled on Google Cloud project
life-dashboard— OAuth credentials next
Bug Fixes
- Claude Haiku wraps JSON extraction responses in
```json ```fences despite explicit instructions — stripped beforemodel_validate_json()inclaude_client.py
Infrastructure
- Supabase schema live —
email_logandpurchasestables with RLS, indexes, and soft delete .env.examplecommitted — documentsSUPABASE_URL,SUPABASE_SERVICE_KEY,ANTHROPIC_API_KEYservices.mdcreated at~/.claude/services.md— global inventory of cloud platforms, APIs, and self-hosted services; updated by/wrapgoing forward- n8n compose file uses
N8N_SECURE_COOKIE=false— required for HTTP access on local network
Lessons
- Smaller models (Haiku) reliably ignore "no markdown formatting" instructions — fence stripping is a required defensive layer, not optional
scpwith~fails in PowerShell; full Windows paths (C:\Users\nrisa\...) required — write files locally and copy over rather than fighting heredocs or rsync- Services inventory needs to exist before the second integration, not after — build the index early so "do I already have a GCP project?" has a real answer
TODO
- Step 5: create Google OAuth 2.0 credentials in life-dashboard, wire Gmail trigger in n8n
- Add
budto save-state project registry via/ship
BUDfeatureinfrastructureai
PurchaseHandler built — schema live, handler ready for local test
Features
- PurchaseHandler complete — extract → validate → store pipeline wired to
/api/v1/handle/purchase - Extraction prompt with three few-shot examples (Amazon order, DoorDash delivery, Netflix subscription) — Haiku model, JSON-only output
HandlerResponsereturnsemail_log_idandpurchase_idon success — n8n gets traceable IDs, not just a status stringamount > 0and non-emptyvendorvalidation gates every write — no silent bad data reaches Supabase
Infrastructure
email_logandpurchasestables live in Supabase — RLS enabled, dedup index onmessage_id, FK from purchases into email_log- Migration files in
migrations/numbered 001/002 — ordering enforces the FK dependency at run time - Sync Supabase client wrapped in
asyncio.to_thread— avoids supabase-py async API fragility across 2.x minor versions email_logrow written beforepurchasesinsert — if the purchases write fails, the audit trail exists and carries the error
Lessons
- Supabase service_role bypasses RLS by design — adding a write policy is redundant and misleading; only anon/authenticated reads need explicit policies
- supabase-py async API changed names between 2.x minor versions (
acreate_clientvscreate_async_client) — sync +asyncio.to_threadis the portable fix - Prompt templates with JSON few-shot examples can't use
.format()— JSON braces read as format variables;.replace()per slot avoids the trap - Write the audit log row first, then the derived row — partial failures are recoverable; a missing anchor row is not
TODO
- Step 2 checkpoint: venv setup,
.env, curl tests with three email formats against localhost - Steps 3–6 after checkpoint: Docker deployment on docker-host, n8n LXC, Gmail trigger workflow, end-to-end validation