← HOME

PROJECT

Bud

ACTIVE

Inbox intelligence

PythonFastAPIDockern8nSupabase

ABOUT

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.

FEATURES

SHIPPED

  • ✓
    PurchaseHandler

    Classifies Gmail purchase receipts and extracts structured data — merchant, amount, items. Deployed on Taproot via Docker.

    MAY 2026
  • ✓
    Finance Dashboard

    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.

    OCT 2026

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.dev behind 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_rules table 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/purchases was 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 INTO after CT 102's root disk filled (grown 7.8 GB to 12 GB)
  • Bud — Gmail Purchase Router unpublished — 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, /autoimport answered "Seems to have worked!", and only docker logs showed 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 — /resume and 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 uncategorized rows from the Chase history import
  • Delete the Vercel project bud by hand — it still holds the old Supabase settings
  • Remove the cloud service_role key and Kroger secret from n8n's compose file
  • Push docs/notes-2026-10-12 and 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 CRD payments 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_liability flag routes card payments, returned payments, and opening balances to transfer
  • 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.py maps accounts by unique ID prefix, repeatable in one request, all-or-nothing, with a config backup before every write
  • simplefin_backfill_once.sh runs one wider import window and restores the 30-day config through an EXIT trap, 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 --map input, and accepted any string as a mapping (including the bare prefix ACT-)
  • .env.example was 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 with COPY scripts/

Infrastructure

  • A Supabase service_role JWT found hardcoded in an n8n workflow export during a pre-push history scan — scrubbed from all 16 commits with git filter-repo --replace-text before the first push, verified absent from every object
  • Private GitHub remotes created for bud and bud-app — a month of finance work no longer lives on one disk
  • .gitattributes forces LF for *.sh — core.autocrlf=true would otherwise hand CT 100 a script bash can't parse
  • tests/test_categorizer.py added — 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_meta query 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_role key
  • Move merchant rules to a Taproot merchant_rules table 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) from email_log, subtracts 1h safety buffer, builds after:YYYY/MM/DD Gmail 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.json v3) — covers Apr 17–May 16 gap period, manual-trigger, limit 50

Bug Fixes

  • Prefer: resolution=ignore-duplicates header alone returned 409 on duplicate inserts — PostgREST requires ?on_conflict=message_id as 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_log for 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-duplicates needs two things: the Prefer header 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 ETIMEDOUT on a Google IP — looks like a network failure, not a credential error; check n8n Credentials before debugging connectivity
  • A gap in email_log doesn'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:3b on 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 to ollama_client.py — walks the parsed JSON dict before Pydantic validation to replace "null" strings with Python None
  • 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 restart was used after code edits — command reuses the baked image and silently runs old code; switched to docker compose up --build -d

Infrastructure

  • src/services/ollama_client.py — new extraction service using httpx against Ollama's /v1/chat/completions endpoint; 1800s timeout; JSON fence stripping; null-string normalization
  • src/config.py updated: anthropic_api_key now optional (defaults to ""); ollama_url and ollama_model added
  • Migrations 003 (unsubscribe support) and 004 (amount nullable on purchases) applied
  • scripts/find_dupes.py — one-shot soft-delete deduplication script; idempotent; dry-run flag
  • workflows/backfill/ — n8n recovery workflow JSON export and Python backfill runner

Lessons

  • docker restart reuses the baked image — Python file changes are invisible until docker 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}/run returns "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 JSON null — 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.amount was required, now optional
  • email_log audit trail never written when extraction failed — crash happened in extract() before store() 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: EmailPayload on docker-host was missing unsubscribe_link and is_read fields — 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 in extract() leaves zero trace otherwise

TODO

  • Confirm end-to-end with a real purchase email landing in both email_log and purchases tables — 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 before model_validate_json() in claude_client.py

Infrastructure

  • Supabase schema live — email_log and purchases tables with RLS, indexes, and soft delete
  • .env.example committed — documents SUPABASE_URL, SUPABASE_SERVICE_KEY, ANTHROPIC_API_KEY
  • services.md created at ~/.claude/services.md — global inventory of cloud platforms, APIs, and self-hosted services; updated by /wrap going 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
  • scp with ~ 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 bud to 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
  • HandlerResponse returns email_log_id and purchase_id on success — n8n gets traceable IDs, not just a status string
  • amount > 0 and non-empty vendor validation gates every write — no silent bad data reaches Supabase

Infrastructure

  • email_log and purchases tables live in Supabase — RLS enabled, dedup index on message_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_log row written before purchases insert — 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_client vs create_async_client) — sync + asyncio.to_thread is 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