Agents to manage personal bills.
People leak money constantly across insurance, rent, lines of credit, taxes, medical bills, utilities, and dozens of other recurring costs. In most cases nobody is actively managing these expenses because the savings on any single line item don't justify the time. But in aggregate, the waste is real. The tools that exist today require a human to initiate each comparison or negotiation, which means it mostly doesn't happen — bills go unexamined and contracts auto-renew at whatever rate the vendor set.
Bonsai is a self-hostable set of agents that runs in the background and continuously looks for savings across every category of spend. Not a dashboard that tells you where you're overpaying — agents that actually renegotiate the electric bill, find a cheaper insurance policy, or flag when a credit line's terms are no longer competitive. The wedge is a general-purpose cost optimization agent for people that quietly saves them money while they sleep.
- Agents that act, not dashboards that report. Bonsai writes the appeal, drafts the email, dials the rep. You approve every move; the agents do the work.
- Grounding contract. Every dispute quotes the source verbatim. Every dollar amount traces back to a line on the bill or a clause in the EOB. Hallucinations don't ship.
- You own your data. Bring your own keys, deploy your own instance. Bonsai never phones home and there is no operator-paid default that could route your traffic through someone else's account.
git clone https://github.com/<your-fork>/bonsai.git
cd bonsai
bun install
cp .env.example .env # fill in ANTHROPIC_API_KEY + BONSAI_PUBLIC_DOMAIN
bun run make-pdfs # synthetic fixture PDFs
bun run serve # http://localhost:3333Bonsai refuses to start until the required env vars are set — see src/env.ts:validateRequiredEnv.
Bill / contract / statement
│
▼
┌───────────┐
│ Analyzer │ Reads any bill, finds errors and overcharges,
│ │ quotes the offending lines verbatim.
└─────┬─────┘
│
▼
┌───────────┐
│ Appeal │ Drafts a grounded letter that cites the source.
│ │ No second LLM, no drift. NSA + FCRA clauses where applicable.
└─────┬─────┘
│
▼
┌───────────┐
│ Contact │ Resolves the right department + channel for the merchant.
│ Resolver │ Web search via Anthropic managed agents.
└─────┬─────┘
│
▼
┌───────────┐ email when the dispute lives in writing,
│Negotiation│ voice when a rep needs to clear a write-off live.
│ (email + │ Holds to a `final_acceptable_floor`. Escalates after
│ voice) │ N flat denials.
└─────┬─────┘
│
▼
┌───────────┐ When negotiation isn't the right path: surveys equivalent
│Comparison │ alternatives (insurance, telecom, utilities, refi, credit),
│ │ normalizes them to true cost, and ranks on net value.
└─────┬─────┘
│
▼
BonsaiReport — findings, letter, transcript/thread, summary $ saved
Analyzer. Claude Opus 4.7 with a tool-use loop. Reads the bill PDF (text-extracted) plus any supporting docs (EOB, contract, statement) and emits structured BillingError rows. Every row carries a verbatim line_quote, a 1-indexed page_number, a confidence tier, and the supporting evidence. Findings that fail the grounding check are rejected and the model retries.
Appeal. A deterministic markdown generator. Reads the analyzer's findings and produces a letter the user can send. No LLM in this step — placeholders are bracketed, NSA / FCRA clauses are added when grounded, and missing metadata renders as [BRACKETED PLACEHOLDERS] instead of being silently invented.
Contact resolver. Locates the right billing-department email + phone for the merchant on the bill. Backed by web search via Anthropic managed agents. Returns a confidence tier so the orchestrator can fall back to user-supplied contact info when the resolver is uncertain.
Negotiation. A two-channel loop:
- Email (
src/negotiate-email.ts) — Claude drafts replies using only facts from grounded findings, has 3 tools (send_email,mark_resolved,escalate_human), and holds to a configurable floor. State persists inout/threads/{thread_id}.state.json. - Voice (
src/voice/) — generates an ElevenLabs Conversational AI agent config with our webhook-backed server tools plus ElevenLabs built-in system tools. The agent navigates phone menus by pressing keys (play_keypad_touch_tonefor DTMF), waits quietly through holds (skip_turn), recognizes voicemail (voicemail_detection) and leaves one short callback message, and warm-transfers the live call to the account holder (transfer_to_number) when a rep needs something only they can give — an identity/security answer or sign-off on a payment. The transfer destination is the phone on the user's profile; without one the agent flags the call for a human instead. Real outbound calls go through ElevenLabs + Twilio when the env is wired; otherwise a dual-Claude simulator role-plays both sides so day-to-day dev never burns minutes.
Negotiation runs in one of two agent modes (per-user setting, top of the Settings page):
- Autonomous (default) — the agent decides when to accept, push back, or escalate. The user only gets pinged when something needs a human (
escalate_human, signature required, or stalemate). - Co-pilot — the agent pauses on every proposed resolution and hands off to the user. The user accepts via
POST /api/threads/:id/acceptor counters viaPOST /api/threads/:id/push-back. AfterMAX_PUSH_BACK_ROUNDSrounds the next push-back force-escalates to a human. Any finding flaggedrequires_signaturealways gates on the user, regardless of mode. Threads that sit inawaiting_user_reviewfor more than 7 days are swept intoescalated_humanso nothing rots silently.
Notifications (durable in-app inbox + Resend email) are written by src/lib/notify-user.ts and read via GET /api/notifications/inbox.
Comparison. When the cheaper move is to switch providers, the comparison agent surveys alternatives via Anthropic managed agents and ranks them on net value, not lowest sticker price: normalized savings × equivalence parity × confidence ÷ switching friction. It works across car/home insurance, internet, mobile, electricity, gas, streaming, mortgage refi, credit cards, and medical — either from a parsed bill or from free-form intake (POST /api/compare, e.g. "I pay $250/mo for car insurance with State Farm").
Three things make a comparison trustworthy instead of a cheaper number:
- Equivalence delta. Every alternative records what you'd keep, give up, and gain against the dimensions that matter for its category (liability limits + deductibles for car insurance, speed + data cap for internet, rate + term for a refi), plus a 0–1 parity score. See
src/lib/comparison-dimensions.ts. - Effective cost. Offers capture promo-vs-standard pricing and one-time fees and blend them into an effective monthly cost over a 12–24 month horizon, so a teaser that jumps after 12 months isn't recorded as a clean win. Refis additionally compute break-even months and only recommend when break-even is reasonably soon AND the term stays similar.
- Independent verification. A second model (GPT-5) scores confidence and flags promo-only or eligibility-gated deals before a recommended offer is shown; verified offers get a badge. Off by default — same
BONSAI_CROSSMODAL=1gate as the negotiation eval passes.
The comparison agent's system prompt lives in src/skills/comparison-agent.md (fat-skills/thin-harness pattern, iterable without a code redeploy); parse-comparison-intake.md and verify-offer.md back the intake parse and the verification pass.
balance_billing finding + HIGH ≥ $1,500 → voice
anything else → email
explicit email/voice → honored verbatim
Voice gets used when a rep needs to clear a write-off live. Below that threshold, email is cheaper, leaves a paper trail, and is harder for the merchant to wave away.
Every finding the analyzer reports must:
- Quote a verbatim row from the bill (
line_quote). If the quote doesn't appear in the source ground-truth, the tool call is rejected withis_error: trueand Claude retries. Seesrc/lib/ground-truth.ts. - Name a 1-indexed
page_number. - Commit to a
confidencetier. Only the canonical HIGH set ships to merchants; everything else isworth_reviewingand never leaves the dashboard. - Justify itself with
evidencefrom the matching reference doc (EOB for medical, contract for telecom/utilities, etc.).
The grounding contract is the difference between "LLM reads a bill" and "agent a person can hand to their insurer / utility / telecom." Hallucinated line quotes don't make it into outbound mail.
The #1 arithmetic mistake when scoring a bill is summing a balance_billing finding with the line items it already subsumes (e.g. balance-billing of $3,812 is caused by 5 denied lines totaling $3,590; summing = $7,402 is wrong, the real defensible total is $3,812).
defensibleTotal =
no balance_billing → sum(HIGH)
has balance_billing → max( max(balance_billing), sum(HIGH ∖ balance_billing) )
src/analyzer.ts:computeDefensibleTotal implements this and is unit-tested. The analyzer also auto-repairs Claude's reported summary if it violates the rule (look for auto-corrected in the headline).
| Layer | Status |
|---|---|
| Analyzer | Live. Grounded, two-tier confidence, overlap-aware totals. |
| Appeal letter | Live. Deterministic, placeholder-aware, NSA + FCRA clauses where applicable. |
| Contact resolver | Live. Web search via Anthropic managed agents. |
| Email negotiation | Live (Resend). Real outbound + svix-verified inbound webhook. Mock fallback for local dev. |
| Voice negotiation | Live (ElevenLabs + Twilio). Simulator runs when any voice env var is unset. |
| Comparison | Live. Equivalence- + effective-cost- + break-even-aware ranking; free-form intake; optional second-model verify. Anthropic managed agents survey alternatives. |
| Web UI | Live. Upload, audit, dashboard with receipts, agent-reasoning timeline. |
Bonsai ships with a Dockerfile and railway.json that get you to a live HTTPS URL in a few minutes. From a fresh clone to the first real audit should take under 30 minutes.
brew install railway # or: npm i -g @railway/cli
railway login
railway init # creates a new project
railway up # uploads + builds via the DockerfileThe URL will 502 until step 2's env vars are set.
| Var | Value |
|---|---|
ANTHROPIC_API_KEY |
sk-ant-... from console.anthropic.com (Opus 4.7 requires a paid plan) |
BONSAI_PUBLIC_DOMAIN |
your deployed domain, e.g. bonsai.example.com |
NODE_ENV |
production (flips cookie Secure flag + webhook fail-closed) |
BONSAI_DATA_DIR |
/app/data (matches the volume mount in step 4) |
Add only what you'll use. Each integration degrades gracefully when its keys are missing — no operator-paid fallback ever runs.
| Var(s) | Enables | Without it |
|---|---|---|
RESEND_API_KEY + RESEND_FROM |
Real outbound email | Email loop runs in mock mode (writes to out/, never sends) |
RESEND_WEBHOOK_SECRET |
Inbound replies from merchants | In NODE_ENV=production the inbound webhook returns 500 (fail-closed) |
All four ELEVENLABS_* vars |
Real outbound voice calls | Voice runs in dual-Claude simulator |
SENTRY_DSN |
(reserved) | No-op today |
Dashboard → Settings → Volumes → New Volume:
- Mount path:
/app/data - Size: 1 GB
Then railway up once more so the volume + env vars are picked up together. Signups, bills, threads, and call transcripts now survive deploys.
After pointing Resend's inbound webhook at https://<your-domain>/webhooks/resend-inbound:
bash scripts/resend-inbound-smoke.sh \
--url https://<your-domain> \
--secret "$RESEND_WEBHOOK_SECRET" \
--debug-token "$BONSAI_WEBHOOK_DEBUG_TOKEN" \
--echo
# expect: signature_valid:true, exit 0If signature_valid:false, the secret on Railway doesn't match the one in Resend's dashboard, or your laptop's clock is more than 5 minutes off (svix replay window).
Gated on the four ELEVENLABS_* env vars. Dials a number you own as a 30-second smoke test:
BONSAI_VOICE_SMOKE_TO=+15555550123 \
BONSAI_VOICE_SMOKE_USER_EMAIL=you@example.com \
bun run scripts/voice-smoke.tsThe phone rings, the agent reads the opening line, hangup writes a transcript to out/users/<id>/calls/.
Railway's volume isn't snapshotted by default — one disk fault loses every user's SQLite, threads, transcripts, and uploaded bills. The server can push a nightly tarball of BONSAI_DATA_DIR to any S3-compatible object store. Backups are opt-in: with the four env vars below unset, the server boots and logs [backup] disabled — nothing else happens.
Recommended: Backblaze B2 (S3-compatible, ~6× cheaper than S3 for storage, cheap egress). Cloudflare R2, AWS S3, and self-hosted MinIO all work too — same env vars.
BACKUP_S3_ENDPOINT=https://s3.us-west-002.backblazeb2.com
BACKUP_S3_BUCKET=bonsai-backups
BACKUP_S3_ACCESS_KEY_ID=...
BACKUP_S3_SECRET_ACCESS_KEY=...What happens after the next deploy:
- On boot, if no successful backup is recorded — or the last one is more than 25 hours old — the server fires a catch-up run immediately.
- Every 24 hours, the same job tars
BONSAI_DATA_DIR, streams it tobonsai-backups/YYYY-MM-DD.tar.gz, and prunes anything older than 30 days. - Backup failures are non-fatal — they log
[backup] FAILEDand the next run retries.
To verify a backup is recoverable:
bun run scripts/restore-backup.ts latest
# Validates the tar contents and prints the exact tar -xzf command to run.
# Never auto-extracts (restore is destructive).Cost note: at typical beta scale (tens of MB per user × hundreds of users × 30-day retention) this runs single-digit dollars per month on B2.
Every env var, in one place. See .env.example for the canonical reference.
| Var | Required? | What it does |
|---|---|---|
ANTHROPIC_API_KEY |
yes | Opus 4.7 access for analysis, appeal, negotiation, comparison |
BONSAI_PUBLIC_DOMAIN |
yes | Your deployed domain — forcing function so a fork doesn't ship someone else's URL |
RESEND_API_KEY |
optional | Real outbound email |
RESEND_FROM |
optional | Verified sender, e.g. Bonsai Appeals <appeals@your-domain.com> |
RESEND_WEBHOOK_SECRET |
optional (req. in prod) | Svix signing secret for inbound webhook |
BONSAI_WEBHOOK_DEBUG_TOKEN |
optional | Unlocks the read-only echo route on the inbound webhook |
ELEVENLABS_API_KEY |
optional | Real outbound voice |
ELEVENLABS_TWILIO_PHONE_NUMBER_ID |
optional | ElevenLabs ID for the imported Twilio number |
ELEVENLABS_WEBHOOK_BASE |
optional | Public root for ElevenLabs server-tool callbacks |
ELEVENLABS_WEBHOOK_SECRET |
optional | Bearer secret for inbound voice tool callbacks |
NODE_ENV |
optional | production flips cookie Secure + webhook fail-closed |
BONSAI_DATA_DIR |
optional | Persistent data dir; defaults to <repo>/out |
PORT |
optional | HTTP port for the web UI; default 3333 |
SENTRY_DSN |
optional | Reserved |
BONSAI_AUDIT_DAILY_LIMIT |
optional | Per-user daily audit cap; default 5 |
BONSAI_VOICE_DAILY_LIMIT |
optional | Per-user daily call cap; default 5 |
BONSAI_VOICE_DAILY_BUDGET_USD |
optional | Operator-wide daily voice budget; default 50 |
VOICE_DRY_RUN |
optional | true logs the call we'd place without dialing |
BONSAI_SUPPORT_EMAIL |
optional | Public support address for the SPA + landing footer (via /api/public-config) |
BACKUP_S3_ENDPOINT |
optional | S3-compatible endpoint for nightly volume backup (B2, R2, S3, MinIO) |
BACKUP_S3_BUCKET |
optional | Bucket name for nightly backups |
BACKUP_S3_ACCESS_KEY_ID |
optional | Access key for the backup bucket |
BACKUP_S3_SECRET_ACCESS_KEY |
optional | Secret key for the backup bucket |
# Development
bun run typecheck # tsc --noEmit
bun run test # bun test
bun run make-pdfs # regenerate fixture PDFs after editing .md
# Per-stage CLIs (debugging)
bun run day1 ... day5 # stage-by-stage harnesses; see scripts/
# Full pipeline
bun run bonsai [bill] [eob] [channel] [persona]
# channel: auto | email | voice (default auto)
# persona: stall_then_concede | hostile | quick_concede | cooperative | voicemail | outright_deny | ivr | identity_challenge
# Web server
PORT=3333 bun run servePOST /webhooks/resend-inbound— Resend posts parsed inbound mail here, signed via svix. Handler verifies the signature againstRESEND_WEBHOOK_SECRET(constant-time HMAC, 5-minute replay window), correlates to a thread (X-Bonsai-Thread-Id→In-Reply-To→References), appends toout/threads/{thread_id}.jsondeduplicated bymessage_id, and kicks onestepNegotiation. Returns401on bad signature,202if no thread correlation,200(idempotent) on duplicate message ids.GET /api/receipts— projects completedout/report-*.jsonfiles into per-bill rows plus a cumulative savings total. The Home page renders a green hero counter and the three most recent receipts above the dropzone.GET /api/threads/:id/state— returns theNegotiationStateplus the thread's outbound + inbound emails. Powers the bill-detail timeline and the proposed-resolution card. 404 if the state doesn't belong to the calling user.POST /api/threads/:id/accept— co-pilot endpoint. The user accepts the agent's currently proposed resolution; thread transitions toresolved. Idempotency-keyed (5-minute TTL) so the UI's confirm-double-click can't double-advance state.POST /api/threads/:id/push-back— co-pilot endpoint. The user counters the proposed resolution with free-text guidance for the agent's next turn. AfterMAX_PUSH_BACK_ROUNDSrounds the next call force-escalates toescalated_humaninstead of looping again. Idempotency-keyed identically.GET /api/notifications/inbox— returns the calling user's notification inbox (durable JSONL written bysrc/lib/notify-user.ts), newest-first. Same records that drive the Resend "your input is needed" emails.POST /api/compare— free-form comparison intake. Accepts adescription(e.g. "I pay $250/mo for car insurance with State Farm") or structuredcurrent_price+categoryfields; parses missing fields with Opus (parse-comparison-intakeskill), then fires a background managed-agent hunt for equivalent alternatives. No upload required. Rate-limited to 10/hour per user since each call can spawn an Opus parse, a full hunt, and a verify pass. Untrusted free-text is length-bounded and clamped before it reaches the LLM.
bun run testNo API keys required. Tests exercise the analyzer's grounding, the appeal letter's placeholder logic, the channel routing heuristic, the email negotiation tool dispatch, and the inbound webhook's svix verify + correlation paths.
src/
analyzer.ts # PDF → errors + metadata via tool-use loop
appeal-letter.ts # deterministic markdown generator
negotiate-email.ts # email negotiation loop (mutex + MAX_TURNS escalation + BCC)
negotiate-agent.ts # negotiation orchestration helpers
offer-agent.ts # comparison agent (managed-agent-backed): net-value ranking + verify
opps-filter.ts # opportunity probability gate
simulate-reply.ts # role-playing rep for email simulator
replay.ts # scripted-inbound demo fallback when webhook unreachable
orchestrator.ts # runBonsai() — single end-to-end entry
server.ts # Bun.serve HTTP + upload + fixture API + receipts + webhook router
env.ts # explicit .env loader + validateRequiredEnv()
types.ts # BillingError / BillMetadata / AnalyzerResult
clients/ # email + email-resend + email-mock
server/ # webhooks (resend inbound + voice dial/webhooks)
tools/ # record-metadata, record-error (grounding contract), finalize
voice/ # agent-config, client, simulator, tool-handlers
lib/ # ground-truth, thread-store, provider-contact, pdf-extract,
# comparison-dimensions (equivalence + effective-cost), auth, db,
# backup, rate-limit, user-settings, notify-user, etc.
skills/ # markdown agent prompts (fat-skills/thin-harness) + _harness runners
scripts/ # day1-poc, day2..5, run-bonsai, voice-smoke, resend-inbound-smoke
fixtures/ # synthetic bill + EOB markdown + generated PDFs
public/ # static web UI (index, landing, terms, privacy)
test/ # bun test
What's next:
- More analyzer rule packs per bill category (rent, taxes, subscriptions).
- Multi-tenant state (current
out/artifacts are file-based; fine for self-host, not for shared deploys).
Explicitly out of scope without a design doc:
- Changes to the grounding contract.
- Model swaps below Opus 4.7 (negotiation + appeal loops are tuned for that capability tier and silently degrade on smaller models).
- Architectural rewrites of the five-agent orchestration.
MIT © 2026 Bonsai contributors.
PRs welcome — see CONTRIBUTING.md. Code of conduct: Contributor Covenant 2.1.
Bonsai is grounded in the source documents. Every disputed finding quotes a verbatim line from the bill, contract, or statement. Dollar totals are overlap-aware. Still: this is a prototype. Not medical, legal, or financial advice. All shipped fixtures are synthetic.