Problem
I tracked my money in Cashew, a local-first app on the phone. I wanted the same accounts on the Mac and the iPhone, usable offline, without a paid server and without a database of mine holding the data.
Other people may use it one day, so it also needed a real Google sign-in and a session that lasts. The storage layer had to be ready to move to an API later without a rewrite.
Architecture
The app is a PWA. It keeps everything in IndexedDB and works with no network. After sign-in, each device writes a gzip snapshot of its data to the appDataFolder of Google Drive, a hidden folder only this app can read, and keeps 30 daily backups there. On sync, a device reads the other devices’ snapshots and merges them record by record.
The only server code is four Vercel Functions for the Google sign-in. They run the OAuth flow with PKCE, keep the refresh token in an encrypted HttpOnly cookie and give the app a short-lived access token. The old Cashew backup is a SQLite file, and the browser reads it with sql.js. Without an account, the app runs as a guest: the data stays in the browser tab and is gone when the tab closes.
How it was built
I started from the stack and the agent setup I use on Kiwici and GUT Music. The doctrine, the rules the agents follow, lives in the repo: an AGENTS.md of 225 lines, plus six rule files of 841 lines in all, each scoped to one part of the code. The gates are 16 shell hooks, and they have tests of their own.
The design canvas
An agent drew the screens as boards on a design canvas: Home, Movements, Accounts and Summary, on desktop and mobile, plus the dialogs for a new movement and a transfer. I asked for changes until I approved the design. The agents then turned the boards into code: their colors and sizes became theme tokens, and the tokens became component recipes. The code names its source board in 98 places. Below, three of the boards sit next to the app as it shipped; both sides show made-up data.






Plans written against the doctrine
The roadmap split the work into phases, D for the screens the canvas lacked (sign-in, settings) and then 0 to 8, with the riskiest part first. On day one, before any data layer, a spike confirmed that Google sign-in from the app installed on the iPhone home screen comes back to the app and not to Safari.
Each step then got its own plan. A plan lists the files it will touch, the rules that apply to them, the open questions and what the gates will cover. The agents asked me 187 questions over the week, and my answers went into the plans as dated decisions. An auditor agent checks the plan against the rules, and a hook keeps the work in plan mode until the plan passes.
Review and commit gates
While an agent writes, hooks put the relevant rules in front of it and lint every file it edits. Then two review agents read the change, one for the code and one against the rules. Either can send it back. A commit goes through only when both have approved every hunk in it, and another hook refuses a commit that changes the shipped code without a new version number. Every push to main is a release on Vercel.
Scope
Mine
- The product: what to build, in what order, and the answers to the agents’ questions
- The architecture calls: local-first, the data in Drive, no paid server, Vercel for hosting
- Approval of the design, of each plan and of each commit
- The spike on my own iPhone
The agents
- The design canvas and its boards
- The roadmap and the plans
- All the code: the PWA, the four auth functions, the sync engine, the Cashew import
- 2,066 unit tests and the end-to-end suite, with a fake Drive that plays several devices
- The review of every change
Key decisions
Local-first, with Drive as the sync
Why. The data lives on the device, so the app opens and works offline, and there is no database for me to run or pay for. Each device writes only its own snapshot; no file on Drive ever has two writers. Every record carries a hybrid logical clock made of the time, a counter and the device. For each record the highest clock wins, and the merge gives the same result in any order, however many times it runs.
Trade-off. The newest version wins the whole record. If I edit the same movement on both devices while offline, one of the two edits is lost. Sync also needs a Google account, so a guest gets no sync and no backups.
A backend only for sign-in
Why. A refresh token kept in the browser can be read by any script on the page. The four functions keep it in a cookie the page can’t read, check for CSRF on the token endpoint and store no data of their own. The sync code talks to an interface, with Drive as one implementation, so an API can take its place later and the same cookie can sign its requests.
Trade-off. The app is tied to Google. Sign-in and sync go through the same account, and the app has to catch a user who unticks the Drive permission on the consent screen and send them back.
Gates in hooks, not in prompts
Why. An agent can skip a rule written in a prompt, and after a long session the rule may not be in its context anymore. A hook runs on every edit and every commit, whatever the agent remembers. The hooks also decide what a review looks at. A model asked to judge “the files of the work” never converges: each fresh run rereads everything, reads the rules a little differently and finds something new on lines it had already passed. So the reviews judge hunks. An approved hunk goes into a registry, and the commit gate compares the diff with that registry instead of trusting an agent’s last answer.
Trade-off. The hooks only see commits that an agent runs through its shell tool, and the first scaffold never went through them. They also cost time. About one verdict in three sends the work back, and every round means more writing and another review.
In code
The clock that orders every write across devices:
// `<ISO ms>-<counter>-<deviceId>`: fixed-width parts, so string order is causal order.
export function nextHlc(last: string | undefined, nowMs: number, deviceId: string) {
const previous = parseHlc(last);
// The wall clock can stall or go back: never fall behind the last clock.
const ms = Math.max(nowMs, previous?.ms ?? nowMs);
const counter = previous && ms === previous.ms ? previous.counter + 1 : 0;
// Out of counter values: borrow the next millisecond.
return counter > COUNTER_MAX
? formatHlc(ms + 1, 0, deviceId)
: formatHlc(ms, counter, deviceId);
}Results
- 68
- end-to-end tests, each run in desktop Chrome and on an iPhone 15 profile
- 7
- days from the first commit to the 34th
- 1 in 3
- review verdicts sent the work back (28 of 81)
MiaFinanza is in production, installed on my iPhone’s home screen, and anyone can try it as a guest. The doctrine and the hooks are in the repo with their tests; the newest gate, the version check, went in on the last day.