What shipping four products after hours taught me
Lessons from building a WhatsApp booking agent, an expense tracker, an Instagram assistant and a booking app in the evenings: what mattered, what bit me, and what I'd do again.
- Building
- Lessons
- Product
- Engineering
My day job is consulting at EY. Before that I was a machine learning engineer at Cofactory.ai and an associate at Goldman Sachs. In the evenings and on weekends, I build things: so far a WhatsApp booking agent, an iPhone expense tracker, an assistant that makes Instagram posts for small shops, and a booking app for a same-day event reel service.
None of them taught me what I expected. The hard parts were rarely the clever parts. This is the list I wish I'd had at the start, with the real moments behind each point.
Build the interface people already use
The first instinct with any product is a dashboard. Charts, filters, a calendar view. I built one for Footfall, the WhatsApp booking agent, and it's useful. But it was never going to be the thing a salon owner opened every morning.
So the owner's daily interface became WhatsApp itself. The agent books appointments in the chat people already use, and the owner gets their alerts in the same app they already have open all day. The dashboard is there when they want the full picture; the daily rhythm happens in chat.
The same pattern showed up in theReelshoot. People don't fill in booking forms for a birthday reel; they message on Instagram or WhatsApp. The site says exactly that. Behind it, a request from the website or the app lands as a lead, and turning it into a booking is a single confirmation.
Latency is geography first
Footfall's database first lived in Sydney. The app felt sluggish, and the cause turned out to be the boring thing: distance. Every request from India was crossing an ocean, sometimes several times.
I moved the database to Mumbai and measured the same calls again.
- typical call, database in Sydney
- 1.9–2.4s
- typical call, database in Sydney
- same call, database in Mumbai
- 0.18–0.27s
- same call, database in Mumbai
- faster, with no code changes
- ~10×
- faster, with no code changes
No index, no cache, no rewrite. Just putting the data next to the people using it. Since then, the first thing I check in a slow app is where each piece physically runs: the database, the API, the functions, the users. Pin them close together, then optimise.
Real use finds the bugs tests don't
theReelshoot's creator app uploads event footage straight from a phone. Photos uploaded fine. Then a real shoot produced a batch of 377 videos, and every video in it failed.
It turned out to be two separate bugs hiding behind each other. The first was a header that iOS spells Etag while my code looked for ETag or etag. The second only appeared after fixing the first: iOS had quietly purged the cached copies of the picked videos, so the queue was pointing at files that no longer existed. I wrote up both in 377 videos, two bugs.
Neither bug was exotic. Both were invisible at small scale, on a simulator, with photos instead of long videos. The lesson isn't "write more tests". It's that the first real, full-size use of a feature is part of building it. Plan for it, watch it closely, and expect to ship a fix the same week.
Let models do what they're good at
All four projects use AI somewhere, and the most useful design decision was being strict about where.
Orbit's homepage has a short film showing a one-line request turning into a finished festival post. It would have been tempting to generate the film with a video model. But a video model can't hold small interface text steady from frame to frame, and a demo where the words wobble looks fake. So the film is rendered frame by frame from code, and an image model makes only the artwork inside it. Each tool does the part it's good at.
The same split runs through everything else:
Models decide
- What a festival caption should say
- How to answer someone in their own language
- What artwork fits a shop's colours
Code decides
- Which booking slots are actually free
- How much is safe to spend today
- What a post costs, and whether it was charged
In Footfall, the agent never decides whether a slot is free; it asks the booking code, which checks again at the moment of booking. I go into how that works in Building a WhatsApp booking agent that can't double-book. In Gravity, "safe to spend today" is arithmetic, explained in One number every morning. And for anything that spends money on model calls, I prefer a fixed workflow to an open-ended agent; that argument is in Workflows, not agents.
"Is it live?" is a question you check
I deploy by pushing to main. Vercel builds and publishes, usually within a minute. That's so reliable that it's easy to stop checking.
Once, I pushed a backend change and moved on. Nothing happened. The build was never triggered at all, probably a lost webhook, so production was still running the previous commit. No error, no failed build to alert on, just silence. An empty commit pushed on top fixed it.
Since then, the backend has a health endpoint that reports the commit it's running, and "done" means I've seen the new commit there.
- 01Push to main
- 02Build starts
- 03Deploy finishes
- 04Health shows new commit
The same goes for any announcement: before telling anyone a fix is live, open the live site and check.
Ship in order: database, backend, frontend
Most of my changes touch three layers: a database migration, the API that uses it, and the web or mobile app that calls the API. They deploy separately, and the order matters.
If the backend ships first, it starts querying a column that doesn't exist yet and every request that touches it fails. If the frontend ships first, it calls an endpoint that isn't there. So the rule is always the same.
- 01Apply the migration
- 02Push the backend
- 03Push the frontend
Migrations themselves should be additive where possible: add the new column, ship the code that writes to it, and only later remove the old one. That way every step works with both the layer below it and the one above it.
Keep the model of the world small
theReelshoot's first booking system had a lot of statuses: inquiry, contacted, quoted, on hold, confirmed, and more. In practice things got stuck between them. Requests sat as half-bookings, and the list meant for new enquiries was empty because everything had been filed somewhere else.
The redesign came down to one boundary. Everything before a customer says yes is a lead. Everything after is a booking. A lead is new, confirmed or lost. A booking moves from confirmed to shot to delivered to closed, and most of those steps are derived from what actually happened (a shoot date passing, files being uploaded) rather than set by hand.
Fewer states meant fewer places for work to hide. When a system feels confusing to the people using it, I now look for statuses to delete before features to add.
Names are harder than code
Gravity started life as Ledger. Then I looked properly and realised Ledger is also the name of a well-known crypto wallet company. That's not a fight worth having, so it became Gravity before it shipped.
Footfall was worse: around twenty candidate names failed basic checks first. One was a sneaker brand. Another was already in use by several businesses. The one that survived is short, says something about what the product does (more people through the door), and came with a domain I could actually get.
What I'd tell myself at the start
Pick one real person and one real job, and make that work end to end before adding anything. Put everything close to your users. Ship the first full-size use early and watch it closely. Let code own anything that has to be exactly right. And check that it's live, every time.
The clever parts were fun. The boring parts are why any of it works.