Pet-care marketplace in Chile
Snup
A pet-care marketplace in Chile, built by one engineer and his agents: 826 commits and 145 new features in the last 30 days.
- Role
- Founder · only engineer since March 2025
- Period
- 2023 – now
- Status
- In production
- Code
- Private
- Live
- snup.cl
- commits in the last 30 days, every one co-written with Claude
- 826
- new features in those 30 days
- 145
- releases in those 30 days
- 61
- commits since March 2025, all mine
- 3,779
- Elixir
- Phoenix
- LiveView
- React
- TypeScript
- MongoDB
- Auth0
- Expo
01 Context
Why it exists
Snup connects pet owners with vetted sitters for boarding, daycare, walks, drop-in visits and overnight care. The marketplace is only half of it. The other half is operations: reviewing sitter applications against a checklist, a support inbox, broadcasts, payouts, cancellation policies each sitter chooses, and a ranking with warnings. That is why the admin app is a first-class product with its own bundle, service worker and release train.
The code came in as an existing Phoenix app (the Cuidog namespace is still there), imported on March 1, 2025. Snup's own earlier stack, React Native apps on a NestJS API, had stalled. Since that import I have been the only engineer: product, architecture, releases and production.
02 In use
What it does
Five screens of the real apps, built from the repository and filled with invented people, pets and messages. The first three are the app pet owners and sitters use on their phones; the last two are the admin app.
The chat gets the most attention. It is where owners and sitters agree on a stay, and I rebuilt it from scratch in 2026: more than 140 CHANGELOG entries since August are about it.
The chat, feature by feature
-
Voice messages, launching soon
Hold to record up to a minute, slide to cancel or up to keep recording hands-free. Each note is transcribed and moderated like text before anyone hears it, and plays at up to 2×.
-
Photos, videos and files
From the camera or several at once, with an upload ring, cancel and retry, and a full-screen viewer.
-
Reactions and replies
Eight quick reactions or the full picker, and a swipe on any message to reply to it.
-
Edit and delete
Within 15 minutes, with edits moderated again, or hide a message just for you.
-
Read receipts, typing and presence
Delivered and read ticks the way WhatsApp shows them, “typing…” and “last seen”. A message counts as read only if it was on screen.
-
Works offline
A conversation opens instantly from the phone's own copy, and messages written without a connection are queued and retried.
-
Search, filters, archive and mute
Search by content, show unread or conversations with a booking, swipe to archive, and mute for four hours, eight hours or a week.
-
Bookings inside the chat
A Book button in the header, and booking messages shown as cards with their current status.
-
Contact details stay protected
Phone numbers, emails and social handles are blocked until the booking is paid, with a warning while you type. Outside links only work when Snup's team wrote them.
-
The admin side
Three columns: conversations, the chat and its context. An admin can read any conversation, join it as Snup's assistant and review blocked messages.
03 Architecture
How it works
Everything ships as one Phoenix release. The router splits it into pipelines: a LiveView marketing site with its own bundles, two React single-page apps served inside HTML shells the server renders, JSON APIs for each, and the MCP endpoint. Data lives in MongoDB through the driver, without schemas.
-
One release, split by pipeline
The landing pages are LiveView.
/appand/admin-appare React apps inside server-rendered shells, so every client route also needs a Phoenix route. The rules file marks that as mandatory, and the admin app keeps a catch-all as a backstop. -
Real-time chat, local first
Phoenix Channels push changes; the PWA keeps each room's full history in IndexedDB and catches up through one diff endpoint. The admin view stays server-authoritative on purpose.
-
MongoDB without schemas, held by code
There is no schema layer: 49 collections of plain documents, and most driver calls go through 45 repository modules. What keeps the data in line is code that runs without anyone having to remember it. Each index is declared next to the query it serves, with a check mode that reports what is missing, and the newer ones are created when the app boots. Unique indexes cover the races code cannot, like a webhook delivered twice or two active pets with the same name. Ids that arrive as an ObjectId or as text go through one normalizer, and one module owns where a person's name, phone and address live.
-
Background work on MongoDB
Five queues kept in Mongo, each drained by a polling GenServer, among them outbound jobs, broadcasts and the chat mirror. Claiming a job is atomic and the lock expires if an instance dies. Scheduled jobs run on Quantum.
-
Mobile as a shell
An Expo app loads the PWA in a WebView. Sign-in runs in the system browser with PKCE, then a one-time code sets the normal session cookie inside the WebView. It is being prepared for the stores.
-
Two release trains
The app and the admin app version separately, each with its own service worker. The CHANGELOG is parsed into the in-app release notes, and every entry has to name the app it belongs to; a test fails if one does not.
04 Decisions
The calls that shaped it
-
Decision 01
Keep the new chat in sync through a persisted event log
- Chose
- Every write to the old chat inserts one event; a worker applies it to the new chat every five seconds through a shared mapper. The switch to the new chat was a configuration flip.
- Over
- A synchronous mirror inline (tried first, it blocked the socket handler in a real room), PubSub (lost on a crash), and a general domain event bus (premature).
- Because
- It survives crashes and deploys, it can be replayed, and every conversation could be checked message by message before the switch.
- Cost
- The new chat lags by seconds, there are two write paths into it, and a reverse mirror has to exist until the old one is retired.
-
Decision 02
Make the chat local-first
- Chose
- A full per-room history in IndexedDB, kept current by a single
updated_sinceendpoint. The server wins on conflict. - Over
- A server cache with a fixed page of messages, or bidirectional sync in the style of RxDB.
- Because
- Opening a room is instant and works offline, and one diff primitive covers new, edited, deleted and reacted messages.
- Cost
- A client store to own (quota, cursors), and edits in rooms you have not opened reconcile only when you open them.
-
Decision 03
Retry external providers from a queue, but try first
- Chose
- Send immediately and queue only transient failures, with backoff from one minute to six hours. Permanent failures are stored and shown in the admin app.
- Over
- Oban, which needs Postgres; retrying in memory, which every deploy loses; and writing everything to the outbox first, which delays every email.
- Because
- Most sends succeed on the first try, so the queue only carries the ones that need it.
- Cost
- An email can occasionally arrive twice.
-
Decision 04
Ship the mobile app as a WebView shell over the PWA
- Chose
- One UI. Every Phoenix deploy is also an app update.
- Over
- A full native app (estimated at four to six months, and the two earlier native apps died that way), Capacitor, and a Trusted Web Activity.
- Because
- One engineer cannot keep two UIs at parity, and the PWA already had push, offline support and installability.
- Cost
- Some risk of store rejection, a message bridge that is a contract between two repositories, and a WebView cookie the server cannot revoke directly.
-
Decision 05
Create indexes when the app starts
- Chose
- Each index lives in code next to the queries that use it, and a one-time task creates it when the app boots. A Mix task with
--checkreports any drift. - Over
- Index scripts run by hand before a release. That is how care requests reached production without their location index and returned errors.
- Because
- A deploy brings its own indexes, and nobody has to remember a step.
- Cost
- It runs once, without retries: a failure only shows in the log, and the Mix task repeats it. One older index is still applied by hand.
05 What is interesting
Worth a closer look
-
One diff primitive does all of sync
Every change to a message bumps
updated_at: insert, edit, soft delete (the tombstone stays), hide and reaction. One query returns everything that changed, and the client upserts by its own id without ever merging. -
A ratchet instead of a schema
A person's name, phone and address were copied across two collections. Moving them behind one module happens in phases, and a test that reads the source fails if direct reads go up, or go down without the limit coming down with them. The count has gone from 157 to 91.
-
The release notes know what is live
The image ships without git, so the server reads the version out of the live service worker files and caches it. That is how the in-app release notes tell shipped from pending.
-
A test loop agents can afford
Moving the suite from a remote database cluster to MongoDB in Docker took it from about 7,400 seconds to 139. That is the difference between agents running the tests and agents skipping them.
-
A voice note is vetted before it exists
A recorded note waits outside the conversation until transcription and moderation approve it. Only then does it become a message, so nothing unvetted reaches sync, push notifications or the chat list.
06 Built with agents
How agents fit in
Snup is built with Claude Code, and the repository is written for that. The rules file is the operating manual: prose in Spanish and identifiers in English, neutral Spanish, the product's vocabulary, icons instead of emojis, the CHANGELOG updated in the same pull request. Each rule carries the reason and, often, the incident behind it.
The last 30 days, counted from the repository: 826 commits and 61 releases, and a CHANGELOG with 145 new features, 141 changes and 291 fixes, from 54 new briefs. Snup took 40 commits in July, 218 in August, 352 in September and 754 in October up to the 26th. In the same weeks I built Agents Control Center and QA Console and started Snup Vets. That is what running agents in parallel gives one engineer.
Work starts from a numbered brief, 105 so far. Agents write their questions into the brief's clarifications section instead of guessing. Architecture decisions go into ADRs, and one of them says why plainly: several agents had each optimized the task in front of them in different directions, because no document held the vision.
Some rules came from failures. An agent's dark-mode sweep across 49 files left 40 texts at a contrast of 1.77, because a line-based check cannot see a background set by a parent element. The sweep was reverted, the fix found 282 Tailwind classes that generate no CSS, and the rule became: validate UI in a running browser and measure contrast, never judge it from the code.
Teammates outside engineering can make changes with Claude too. A guide keeps the language plain and ends the flow at a pull request tested locally, and a hook blocks pushes to protected branches, history rewrites and merges in their sessions.