Cristian Carreño

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
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.

A sitter's profile in the Snup app: an illustrated dog as avatar, name, neighbourhood, a 4.9 rating from 12 reviews, badges for dogs and cats approved in Snup's training, and a contact button. A sitter's profile in the Snup app: an illustrated dog as avatar, name, neighbourhood, a 4.9 rating from 12 reviews, badges for dogs and cats approved in Snup's training, and a contact button.
Simulated data A sitter's public profile as a pet owner sees it: background check, species approved in Snup's training, reviews and a direct contact button.
A chat between a pet owner and a sitter: the sitter asks about the dog's routine, the owner answers with a 17-second voice message shown with its transcript, then replies to an earlier message about Thursday. Messages carry reactions and read ticks, and the header has a Book button. A chat between a pet owner and a sitter: the sitter asks about the dog's routine, the owner answers with a 17-second voice message shown with its transcript, then replies to an earlier message about Thursday. Messages carry reactions and read ticks, and the header has a Book button.
Simulated data An owner and a sitter arrange a visit: a voice message with its transcript (launching soon), a reply to an earlier message, reactions, read receipts and a Book button in the header.
An open care request for four nights with two pets, at step 3 of 4, with three sitters who offered and sort options, and the first offer card. An open care request for four nights with two pets, at step 3 of 4, with three sitters who offered and sort options, and the first offer card.
Simulated data An owner's open request: nearby sitters offered to take it, and she compares, sorts and chats with them before paying anything.
The admin app reviewing a sitter application: actions to approve or reject, the dog quiz passed 4 of 4, and a review checklist with one rejected item and a suggestion to ask for a correction. The admin app reviewing a sitter application: actions to approve or reject, the dog quiz passed 4 of 4, and a review checklist with one rejected item and a suggestion to ask for a correction.
Simulated data Reviewing a sitter application: automatic checks fill part of the checklist, an admin marks the rest, and the panel suggests asking for a correction.
Release notes in the admin app, filtered by App or Admin, with a card for the version still in testing and the latest release's changes grouped as new, security and changes. Release notes in the admin app, filtered by App or Admin, with a card for the version still in testing and the latest release's changes grouped as new, security and changes.
Simulated data Release notes inside the admin app, parsed from the CHANGELOG, with what is still in testing kept apart from what production runs.

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.

Snup architecture One release, many surfaces. The store app wraps the PWA; admins also reach Snup from Claude over MCP; background work runs on queues kept in MongoDB. Clients One Phoenix release Data and services Store app Expo, in progress Web apps React PWA and admin Claude MCP, admins only Router + shells LiveView, SPA HTML JSON APIs app, admin, MCP Channels live chat diffs Mongo queues retries, mirror, hooks MongoDB Atlas driver, no schemas Providers auth, pay, email, push wraps the PWA loads shell JSON + cookie WebSocket MCP + OAuth driver calls retried sends
One release, many surfaces. The store app wraps the PWA; admins also reach Snup from Claude over MCP; background work runs on queues kept in MongoDB.
  1. One release, split by pipeline

    The landing pages are LiveView. /app and /admin-app are 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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

  1. 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.
  2. Decision 02

    Make the chat local-first

    Chose
    A full per-room history in IndexedDB, kept current by a single updated_since endpoint. 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.
  3. 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.
  4. 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.
  5. 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 --check reports 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.

Contact

Want to go deeper on Snup?

Reach me on LinkedIn