Cloudflare Worker Relay: The Coordination Layer Behind Every PupPal Check-In — PupPal
2026-08-22
Cloudflare Worker Relay: The Coordination Layer Behind Every PupPal Check-In — PupPal
When you point PupPal at your dog and tap the shutter, the photo you see on your screen is only half the story. The other half happens inside a Cloudflare Worker relay — a small TypeScript program deployed to Cloudflare Workers that sits between your phone and the Hermes Agent that actually thinks about your pet. That relay stores your photo in R2, checks your subscription tier and your rate limits in KV, spins up a Durable Object to host the AI conversation, and forwards a webhook that starts the whole analysis pipeline. Almost nobody sees it, but nothing in PupPal works without it.
This post is a tour of that relay, grounded in the real source code that powers it. We will look at the entry point in src/server.ts, the R2 bucket where every check-in photo lands, the three KV namespaces that carry subscriptions, care sessions, and rate limits, the Durable Objects that host the AI agent and the real-time signal rooms, and the webhook bridge that ties the whole thing to Hermes. By the end, you will understand why a pet app with no caregiver accounts, no social graph, and a self-hosted AI brain still needs a coordination layer in the cloud — and what that layer actually does with your photos and your data.
Why the Relay Exists: Coordinating Devices Your App Never Sees
PupPal looks like a single app, but it is really a small federation of devices and services. An owner checks in from a Flutter app on iOS or Android. A neighbor with a care code checks in from a web page that requires no account. Photos travel by WebRTC when the two phones can talk directly, and fall back to the cloud when they cannot. Meanwhile the Hermes Agent that performs vision analysis, anomaly detection, and the nightly review lives somewhere else entirely — often on hardware the owner controls. Somebody has to connect all of those pieces, and that somebody is the relay.
The relay is a single Cloudflare Worker whose main file is src/server.ts. Its fetch handler follows a deliberate order. First it lets the agents framework route any WebSocket connection automatically. Then it applies a coarse per-IP rate limit — thirty requests per minute per IP, tracked in memory — before it even looks at the path. Then it answers CORS preflight requests, and only then does it dispatch to the route handlers. The route table reads like a map of the product: /puppal-photo for check-in photos, /puppal-care-create, /care, and /puppal-care-end for the care-code lifecycle, /puppal-sync-* for cross-device synchronization, /puppal-state and /puppal-timeline for pet history, /api/subscribe/* for billing, and /ai/config for default model configuration.
The important design choice is what the relay does not do. It does not run the AI. It does not store the owner social graph. It does not decide what a photo means. Its job is coordination: move bytes to the right store, check the right limits, open the right session, and hand the work to the right service. The heavy thinking belongs to Hermes, and the heavy feelings belong to your phone. The relay is the messenger, the bouncer, and the filing cabinet all at once — and it does all three jobs on infrastructure that needs no servers to babysit.
Why Cloudflare? Because the Worker runs in 300-plus locations and a few milliseconds from whoever is checking in, because R2 and KV cost pennies per pet, and because Durable Objects give a stateful AI session a stable home without standing up a database server. The result is a backend that scales from one dog to a million dogs without a single instance to manage. For a product built by a small team, that is not a footnote; it is the whole business model.
R2: Where Every Check-In Photo Lands
Every photo that cannot travel directly between two devices ends up in R2, Cloudflare's zero-egress object storage. The binding is called PUPPAL_R2 and the bucket is named puppal-assets. Check-in photos are stored under a key that encodes their meaning: photos/${dogId}/${photoId}.jpg, where photoId is a fresh UUID generated at upload time. Three-dimensional pet assets live under a separate ai-assets/ prefix, kept distinct so the check-in pipeline never has to scan through 3D files.
The upload path in src/routes/photo.ts shows how carefully the relay treats the caregiver channel. When a caregiver uploads through the care-code flow, the request must be multipart/form-data, the file must be a JPEG, PNG, or WebP, and it must be 10 MB or smaller — anything else gets a 413 or 415 response before it ever touches storage. The caregiver note attached to a photo is truncated at 500 characters so that a single KV record never balloons. And critically, the caregiver identity is not taken from the form fields at all: if a care_token is present, the relay validates it, and then overrides dog_id, source, and care_session_id from the token's session. Forged form fields are simply ignored.
The owner channel is deliberately lighter. The owner app already authenticates through its own flow, so the owner can send a JSON payload with a photo_url directly — useful when a photo was uploaded by another device and the owner app simply registers it. In both channels, the relay computes a public URL of the form ${origin}/r2/${key} and passes that URL to the webhook that triggers AI analysis. That subtle detail matters for privacy: the Hermes Agent sees a URL, not the binary. The photo lives in R2, the analysis happens against a reference to it, and the relay controls who can fetch what.
There is also a staged fallback path worth understanding. If a caregiver checks in while the owner is offline, the photo cannot be analyzed immediately — nobody is listening. The relay accepts it anyway, marks it staged, stores it in R2, and records its metadata in D1 so that when the owner comes back, the pull request can find it. The owner fetches it and the AI analysis runs locally. R2 is not just an archive; it is also the mailbox for check-ins that arrive when the recipient is away. For the full walkthrough of what happens after the photo lands, the photo check-in deep dive follows the same bytes from shutter to analysis.
KV: Subscriptions, Care Sessions, and Rate Limits
R2 holds the heavy objects; KV holds the small but critical facts. The relay binds three KV namespaces, and each one has a single, sharp job.
The first is SUBSCRIPTIONS, and despite its name it holds more than subscriptions. Under keys like sub:${clientId} it stores the subscription record that the billing webhooks write — a tier number and an expiry date — and the relay reads it every time a skill is requested to decide whether the user is free or pro. Under keys like care:${careCode} it stores the entire care session: dog ID, session ID, PIN hash, start and end times, transfer mode, and the care handbook snapshot, all with a thirty-day TTL. Under keys like rl:care:${careCode} it stores the brute-force protection counters for PIN verification. One namespace, three families of keys, each with a clear expiration policy.
The second namespace is AI_RATE_LIMIT_KV, the sliding-window counter store. The rate limiter in src/security/rate_limit.ts keeps a JSON array of timestamps per key, drops everything older than the window, and enforces a limit per window with a retryAfter hint. Limits are tiered and per-feature: a free user gets 60 device requests per hour and 5 photo analyses per day, while a pro user gets 120 per hour and 20 per day. Daily review, daily voice, pet 3D, and chat each have their own windows — 2 daily reviews and 1 daily voice for free users, 5 and 5 for pro. When the relay checks a skill on the WebSocket, it resolves the tier, loads the limits, checks the device-level hour window, then the per-skill daily window keyed by dog or device. That is how a popular post about a cute puppy cannot accidentally bankrupt the AI budget.
The third namespace is AI_CONFIG_KV, the hot-config store. Two keys matter: model_config and rate_limits. Operations can change which model powers photo analysis or what the daily limits are by editing a single KV entry — no redeploy, no version bump — and the code validates every value before trusting it, falling back to compiled-in defaults when the KV entry is missing or corrupt. The relay also serves a default bring-your-own-key configuration at /ai/config so a fresh install can run on a free model out of the box. KV hot config is the reason the relay can tune itself live while the app sleeps.
The care-code flow shows KV at its most security-conscious. A care code is nine digits, modeled on the RustDesk connection code and grouped 3-3-3 so it can be spoken over the phone. The PIN is four digits, and it is never stored in plaintext: the relay hashes it with SHA-256 plus the CARE_CODE_SECRET and compares hashes with a timing-safe equality function. Verification is throttled to five attempts per minute per code, and ten wrong PINs lock the code for a full hour. Those counters live in KV too, so the lock survives across relay instances. The care codes and PIN post explains the full security model from the user side; this is the machinery underneath it.
Durable Objects: The Home of the AI Agent Session
Rate limits alone are gatekeeping; the actual AI conversation needs a home. That home is a Durable Object, and the relay builds it with the agents framework. The class is PupPalAiAgent, exported from src/server.ts, and it extends Agent from the framework so that a WebSocket connection to the worker is automatically routed to a DO instance that lives as long as the session needs it.
Every connection is authenticated before it is allowed to speak. In onConnect, the relay calls validateAuth against the request; a failed check gets a close code of 1008 — policy violation — and the socket is never granted a message. A successful connection is tagged with the caller's deviceId, which the rate limiter and the tier resolver both use later. Only after that handshake does the agent accept messages.
The message loop in onMessage is a small dispatch table, and it mirrors the Hermes skills: photo_analyze, daily_review, care_handbook, daily_voice, chat, and pet_3d, plus a cancel that aborts an in-flight request. Every skill handler follows the same protocol: an ack acknowledging the request, stream chunks as the model generates text, progress updates with a percentage, a final done with the assembled result, or an error that carries a machine-readable code and a retryable flag so the app knows whether to try again. Unknown message types get an unsupported_skill error. The wire format is deliberately simple — JSON envelopes with an id, a type, a status, and a payload — which is exactly why the app can render a photo analysis answer as it streams in instead of waiting for a single slow response. Speed of check-ins is a user-visible feature, and the Durable Object is where that speed is orchestrated.
Before any of those skill handlers run, the relay applies checkSkillRateLimit: the tier is resolved from SUBSCRIPTIONS, the limits are loaded from AI_CONFIG_KV, and both the hourly device window and the daily skill window must pass. Only then does the request reach the model call. The DO instance also holds the conversation context for the session, which is why a long multi-turn chat with your pet's caretaker stays coherent — the state lives in the same object that handles the messages, not in a database that has to be fetched on every turn.
Why a Durable Object rather than a plain stateless handler? Because an AI session is stateful. It has a conversation, a device identity, and a rate-limit budget, and it streams events over a live socket. Durable Objects give that state a fixed address on Cloudflare's network, so the socket can reconnect to the same session, and the object can be created and destroyed on demand. The relay never pays for a session that is not happening, and it never loses one that is. The Hermes Agent skills architecture post describes the five skills from the agent side; the Durable Object is where those skills are invoked from the cloud.
Signal Rooms: Real-Time Care Without Accounts
The AI agent is not the only Durable Object in the relay. Care transfers and cross-device synchronization each get a signal room — a lightweight WebSocket hub that connects peers without storing a single message.
The CareSignalRoom is a thin shell over a shared SignalRoomCore. A client connects with a WebSocket upgrade carrying member_id and a role of sender or receiver; anything else is rejected. The core tracks who is in the room and relays JSON envelopes to the right member, and the shell handles the messy real-world parts. A zombie connection from a dead mobile network is kicked with close code 1012 — service restart — so the newly connecting device takes over its role instead of being blocked forever. Binary messages are refused. When a connection drops, the room broadcasts the departure so the other side knows immediately.
The clever part is hibernation. Cloudflare Durable Objects can go to sleep when idle, and the room takes advantage of it: member identity is serialized onto each WebSocket as an attachment, so when the object wakes from hibernation it can rebuild its membership table from the live connections without any stored state. An empty room costs nothing and is evicted automatically. That is how the relay can run thousands of care sessions — one per dog, one per weekend trip — without a single always-on server.
The IsarSyncSignalRoom does the same trick for the app's cross-device synchronization. When an owner adds a second device, the two devices need to know when the other has pushed changes. The signal room is the notification bell; the actual change data travels over plain REST into D1, the relational database the relay keeps for durable metadata. Photos that traveled by WebRTC never touch the cloud at all — the metadata row exists precisely so the room can track what was delivered where. The transfer mode is even an explicit field on every care session, chosen at creation: p2pWithFallback, p2pOnly, or cloudOnly, defaulting to the most forgiving option. The relay negotiates none of this at runtime; the owner chooses, and the relay honors it.
This is the piece that makes care codes feel magical. Grandma opens the H5 page on her own phone, types a nine-digit code and a four-digit PIN, and suddenly she can check in on the dog — with no signup, no password, no app install. Behind the scenes, the relay verified her PIN against a hash, issued a short-lived upload token capped at seven days, and opened a signal room slot for her session. She is a guest of the house, and the house has a very good doorman. The care handbook post shows what she finds once she is inside.
Webhooks, Graceful Degradation, and the Hourly Cleanup
Every significant event in the relay eventually needs to reach Hermes, and the bridge for that is a webhook client with one job: POST a JSON payload to ${HERMES_WEBHOOK_BASE}/${endpoint} with an X-Webhook-Secret header. The photo pipeline calls puppal-photo. Care creation, verification, and ending call puppal-care-create, puppal-care-get, and puppal-care-end. Pet state and timeline reads call puppal-state and puppal-timeline. Each carries just enough context — dog ID, photo URL, session ID, timestamp — and Hermes, being self-hosted, is pointed at by a base URL that the owner can control.
The relay is engineered for Hermes to be down. The care path wraps every webhook call in tryHermesWebhook, which swallows errors and returns an empty result, so creating a care session, verifying a PIN, and ending a session all succeed even when the AI gateway is unreachable. A care code degrades gracefully: the handbook snapshot saved at creation time is served back during verification, and the session works without any AI at all. The core promise of care — that a trusted person can step in for your pet — never depends on a model being online. When Hermes comes back, the webhook fires again naturally.
Housekeeping runs on a schedule. The worker declares a cron trigger, 0 * * * *, once per hour. The scheduled handler calls expireCareSessions, which lists every care: key in KV, marks sessions whose end time has passed as ended, notifies Hermes with a puppal-care-end webhook and reason expired, and then cleans up: R2 binaries for sessions that ended more than seven days ago are deleted, while their metadata rows stay in D1 for the record. Photos live as long as they are useful and no longer. That single loop keeps the bucket from filling with expired vacation photos and keeps care sessions from lingering in a half-open state forever.
Taken together, the hourly cron, the TTL-expiring KV keys, the auto-evicting Durable Objects, and the R2 lifecycle rules form a self-cleaning system. The relay never needs a human to run a cleanup script, because every piece of state carries its own expiry. That is the quiet genius of building on serverless primitives: the platform's natural decay is the product's housekeeping.
What the Relay Means for You and Your Dog
It is easy to read about R2, KV, and Durable Objects and file the whole thing under infrastructure trivia. But the relay's design choices land as user-visible behavior, so it is worth naming them plainly.
First, your photos have a home with rules. Check-in photos are stored in R2 under your dog's key, served back to your devices, and deleted once their care session has been over for a week. The AI that analyzes them never gets the binary — it gets a URL. Nothing in the relay implies permanence, and nothing implies exposure.
Second, care is genuinely account-free. The nine-digit code, the four-digit PIN, the hash-and-lock verification, the short-lived upload token, the signal room with no stored messages — this stack is why your mother can check in on your dog from a browser with no account and no app. The relay keeps that promise honest with real security machinery, not just a friendly login screen.
Third, the product can be tuned without waking a server. Rate limits and model choices live in KV and can be edited by an operator in seconds. A free tier that is too generous, a model that is too slow — these are configuration changes, not release trains. For a small team shipping a pet app, that operational leverage is the difference between a side project and a service.
And fourth, the relay never blocks the core promise. Photos, care codes, and the nightly rhythm of check-ins all keep working when the AI gateway is offline, because every webhook call in the care path degrades to a graceful no-op. Your dog's care never depends on a model being reachable at the exact moment your caregiver knocks on the door. If you want to see how the nightly rhythm arrives on top of this infrastructure, the daily health review post shows where the relay feeds the 21:00 trend calculation, and the Rust AI engine post explains the native core that talks to it over this very bridge.
The Cloudflare Worker relay is the invisible spine of PupPal: R2 for the photos, KV for the subscriptions, limits, and care sessions, Durable Objects for the AI conversations and the real-time rooms, and a webhook bridge that keeps the whole system honest about what it stores. The next time you snap a morning photo of your dog, remember that a relay somewhere near you just filed it, checked your budget, opened a conversation, and woke up an agent to look at it — all in the time it took your dog to look back at you.
Ready to meet the relay in person? Download PupPal, snap your first check-in photo, and watch the coordination happen — you will feel it in the speed of the analysis and the calm of knowing who holds your pet's data. One photo a day is all the relay needs to start keeping the history that keeps your pet safe.