The AI agent that protects recurring revenue.
An agent that reads a merchant's PayPal activity, works out which subscriptions are failing, disputed, or quietly unused — and then acts: it proposes refunds, cancellations, and dispute resolutions. Every action is proposed by the agent, approved by a human, executed through official PayPal APIs, and logged.
Status: working end to end (Oct 2026). The detector engine, the bounded agent, the action layer, the Worker API and the React UI are all built, wired, and green — 806 tests across unit, integration and e2e. A real sandbox refund has been issued through the product path. Sandbox only — no live money is ever moved.
Why it is merchant-side. PayPal's transaction and refund APIs are merchant-scoped: a refund requires the credentials of the merchant that captured the payment, so no API lets a buyer's agent reverse a buyer's charge. Spendless therefore operates on a merchant account it holds credentials for, and protects that merchant's revenue. The consumer story survives only as a read-only mode over a PayPal CSV export, which is the one surface that names a payee.
- Reads a merchant's recurring-payment activity (PayPal Transaction Search, or a CSV export, or a seeded multi-month history — see The three data lanes below).
- Detects, with pure deterministic functions: recurring subscriptions, idle ones renewing soon, price increases, and failed renewals. Every figure on screen traces back to one of these functions — never to the model.
- Reasons with an LLM agent that classifies each finding, ranks it by urgency, and drafts the one sentence a merchant reads. The agent cannot compute an amount and cannot move money — those are schema-enforced impossibilities, not prompt requests.
- Acts, after a human clicks approve: refund, cancel, or resolve a dispute through PayPal's MCP server. The chain is
guardrails → kill switch → store (compare-and-set) → executor, and every step can refuse. - Logs every LLM call, tool call, rejection, and result — the run log is the centrepiece and the proof the agent is constrained.
The AI is not a caption generator:
- It judges ambiguous, merchant-specific situations the detectors only surface — which idle subscription is worth acting on this week, whether a cancellation or a refund is the right move, how to describe a merchant the cancel-guide has never heard of.
- It is held inside a bounded loop with a closed, typed tool allowlist (
list_findings,check_cancel_guide,record_proposal). There is deliberately no execution tool — the agent sits before the action layer, so the app degrades gracefully and no money moves that a human did not approve. - Every hallucination is caught and recorded, not trusted: an invented tool, a malformed argument, a proposal naming a subscription nobody pays for, a URL in a cancellation step — each is refused and shown on screen.
A PayPal sandbox cannot backdate a transaction, so no amount of live seeding produces the multi-month date spread that recurrence inference needs. The app therefore runs three clearly-labelled lanes, and the UI must always say which one a figure came from:
| Lane | Carries | Role |
|---|---|---|
| Live sandbox | real reads, a real identity round trip, and a real refund | Proves it acts on real PayPal. |
| Seeded history (in D1) | a realistic multi-month series under the same merchant keys | Gives the detectors the date spread only history can provide. Labelled seeded_history on every row — never presented as a live payment. |
| CSV | a consumer-narrative export | The only PayPal surface that names a real payee. Read-only. |
Ports and adapters. Everything above the adapter layer depends only on the AccountSource / ActionExecutor interfaces — never on PayPal MCP, OpenRouter, or the filesystem directly. Dependencies point downward only: UI → Worker API → run log → action layer → agent → core → adapters. The core imports nothing from the layers above it, and an architecture test fails the build if any module above src/adapters/ imports openai, @paypal/agent-toolkit, or node:fs. That seam is the entire risk hedge: if the MCP adapter covers everything, the others can be deleted at no cost.
See ARCHITECTURE.md for the full design and build order, and AGENTS.md for the invariants.
# Node 20 or 26. On this repo better-sqlite3 needs a native build; Node 26 works with
# better-sqlite3 @13. Use nvm to pick a version, then:
bun install
bun run verify # typecheck + all 806 tests (unit, integration, e2e)
# Credentials (never committed). Copy .env.example to .env and fill in:
# PAYPAL_CLIENT_ID, PAYPAL_CLIENT_SECRET (sandbox business app, Transaction Search enabled)
# OPENROUTER_API_KEY
bun run check-env # confirms both credentials actually work, without printing them
bun run dev # the UI + Worker locally (Vite + workerd)The one-click seed control in the UI creates the scenario; Run the agent streams the run log; Approve runs the action chain.
| Language | TypeScript (strict) |
| Runtime / tests | bun · vitest (three tiers: unit / integration / e2e) |
| Data | Cloudflare D1, raw SQL + zod — no ORM |
| Hosting | Cloudflare Workers + D1 — a free *.workers.dev subdomain is a valid demo URL |
| LLM | OpenRouter — mistralai/mistral-small-3.2-24b-instruct (the 8B pick could not emit valid tool arguments) |
| PayPal | @paypal/mcp over stdio, sandbox only |
| Direct cash cost | ≈ ₹0 — no domain, free tiers throughout |