It started with one bus screen. Then came a YouTube dashboard. Then I wanted a screen for the kids' habits, an LED matrix and maybe a family dashboard, and I did not want to solve authentication, caching, schedules and notifications again for each one.
So underneath the projects sits a small platform. This page covers how it is shaped, the rules it follows and why. Two projects grew on it and have their own write-ups, the morning bus screen and the Pokémon channel dashboard.
The idea in one sentence
Devices call my API. They never call upstream providers directly.
Everything else follows from that. Provider keys live in Cloudflare and nowhere else, so a screen never holds a secret worth stealing. And because the backend returns what the data means, not how to draw it, the same data can feed an e-paper screen today and an LED matrix, a web page or an agent tomorrow.
The shape of it
The backend has three kinds of building block, plus the clients.
- Domain Workers. Each owns one subject, such as transit, weather, YouTube or notifications. A domain owns its provider credentials, its caching and the cleanup of its data, and it exposes a small typed interface. It knows nothing about any screen.
- Project Workers. Each owns one experience, such as the morning commute, the channel companion or the household habits. A project combines any number of domains into a small, versioned response about what the data means. It makes the decisions that only matter to that experience, like "leave in 4 minutes for the 176".
- The gateway. It is the single public front door for devices and apps. It handles routing, authentication, rate limits and common errors, and it contains no business logic. A second public hostname serves the web dashboard.
- Devices and sites. They render. They own pixels, fonts, pins, power, input and offline behaviour, and they read a project's contract to decide how to show it.
Eight Workers run in production. A ninth, the habits ledger, is built and waiting to be deployed.
Design decisions, and why
Three layers, with a promotion path
The cheapest way to try an idea is an experiment. It is an isolated package that may call a provider directly while I work out what I need. It has no public hostname, and I delete it if it does not earn its place.
An integration gets promoted to a domain Worker only when one of three things becomes true. A second consumer needs it, it owns an important secret, or it grows real caching or retry logic. Until then an abstraction is extra code.
One rule I keep repeating. Do not fork a domain just to support a different screen layout. A new display that needs the same data needs a new renderer, not a new backend.
Private by default
Domain and project Workers have no public route, no workers_dev hostname and no preview URL. They call each other through Cloudflare service bindings and typed RPC, so there is no public HTTP hop to attack or rate-limit. Only two public hostnames exist, one for the gateway and one for the dashboard. The repository's rules forbid adding a third without my explicit approval. The platform also stays well apart from my main website and its DNS.
Independent deployment
Changing one Worker must never force me to redeploy the others. The deploy order is the only coupling. Domains go first, then projects, then the gateway and sites, so a gateway never points at something that does not exist yet. Devices are flashed, not deployed, so a backend deploy never needs a flash and a flash never needs a deploy.
Contracts that cannot drift
Every response a client reads is a public contract with a schemaVersion, and that version is a promise.
- Within a version, change things only by adding. New fields are fine, and so are new enum values that clients already treat as unknown.
- Never rename, remove or retype a field, and never change its meaning.
- A breaking change gets a new version on a new route, served next to the old one until every client has moved.
To keep that honest, the contracts package holds example payloads, one full and one degraded for each device-facing project. The TypeScript is type-checked against the contract and the JSON is generated from it. The firmware's own tests parse the same JSON files. I tested the safety net by renaming a field on purpose. The type-check failed, and the regenerated JSON then failed the firmware's parser test, all before anything could reach a screen. The fixtures only ever use made-up places, channels and people.
One repository for the backend and the firmware
The firmware used to live in its own repository next to the backend. A contract change needed two commits, a hand-copied fixture in each repository and two half-finished branches. I moved the firmware into a devices/ directory and kept its history. What matters is independent deployment and flashing, not separate repositories, and a directory boundary keeps both. Backend code never imports from devices/, and firmware never reaches past the public API.
gateway/api/ public gateway
services/ domain Workers: transit, weather, youtube, notifications
projects/ project Workers: commute, channel-pulse, habits
sites/ web renderers (the channel dashboard)
experiments/ isolated prototypes, added as needed
packages/ shared contracts and the schedule package
devices/ firmware (PlatformIO), outside the pnpm workspace
scripts/ maintenance and type-generation helpers
Who may ask for what
A single reviewed manifest lists the clients, meaning the devices, apps and scripts, and the projects each may read. A script generates the gateway's access configuration from it, and the repository's checks fail if the two drift apart. Each client has its own bearer token, kept as a secret, so I can revoke one device without touching the others.
Rate limits are keyed by the authenticated device ID, never by the token itself, and they apply at the gateway before any project runs. Reads get one request a minute per project, device and location. Writes get a more generous burst. Write routes also require JSON, a size limit and an idempotency key, so a retried request is harmless.
Apps decide when to pay attention
A shared household calendar knows which days are school days and which are free days. Each app declares its own attention windows over it, the times its data deserves a look. The bus screen cares between 06:15 and 07:45 on school days. The channel dashboard has its own windows.
Schedules belong to the app, not the data. The YouTube warehouse keeps collecting around the clock, but outside its windows the dashboard's numbers stay frozen, so even a device that ignores the schedule cannot turn into a live counter during school.
Honest, degradable responses
A composed response can be partly broken. The weather Worker fetches three feeds in parallel and settles each on its own, so if lightning data is down the forecast still shows. Responses say which sources were available and how fresh they are, so a screen can say "unavailable" instead of going blank or lying.
Minimal infrastructure
The list of things I have not built matters as much as the list I have. There is no KV, Queues, Durable Objects, Workflows, user accounts, identity provider, central logging, Docker, Kubernetes or Turborepo, and each stays out until a concrete requirement shows up. What does exist earned its place.
- D1 for the YouTube warehouse. History that YouTube no longer serves can never be fetched again, so I store it.
- Small D1 databases elsewhere. Notifications, the channel companion and habits each own one. Notifications keep delivery keys and subscriptions, the channel companion keeps goals and the journal, and habits will keep an append-only ledger once it is deployed.
- One private R2 bucket. It holds source report files and archived thumbnails.
- Cron triggers. Every five minutes for collection and news, and hourly for clean-up.
- A rate-limit binding and one Google authorisation. The binding sits at the front doors. The authorisation is used only by the YouTube Worker to read my own channel. It is not a sign-in for anyone.
Notifications as a domain
Projects decide what is worth saying and when. The notifications domain decides how to reach people. A project sends a semantic event with recipients, importance and a dedupe key. Each key is delivered once per recipient and remembered for 400 days, so a subscriber count that dips and recovers is not announced twice. Delivery channels are adapters. A pull inbox for household devices came first, then encrypted Web Push for phones.
Family data
Some of this data belongs to children, and the repository's rules say so plainly. Channel and habits data stay behind sign-in. The platform stores no subscriber or commenter identities. Comparisons are only ever with the creator's own earlier videos. Logs carry counts, never names, notes or points. A lapse in Google access pauses collection and warns me, and it never deletes anything.
Secrets, tests and checks
Secrets go in only through the interactive Wrangler prompt or a git-ignored local file. They never appear in source, configuration, commands, logs, tests or documentation. Tests use local mock providers and never touch live credentials or data. Each Worker's environment types are generated, never hand-written.
One command runs the whole check, covering the client manifest, formatting, lint, type-check and tests, with deploy dry runs on top. There is no automatic CI/CD. Hosted CI is budget-limited on my account, so deploys are deliberate and ordered.
Backups
Cloudflare's point-in-time recovery for D1 reaches back 30 days, which is not enough for a warehouse. A scheduled job on my Mac copies every database out of Cloudflare each Sunday morning, keeps the newest eight copies and mirrors the archived images. It only reads from production.
A request's journey
The screen asks once a minute while the morning view is active. The gateway checks who it is, whether that client may read this project and whether it is asking too fast. Then it makes a private call to the commute project, which asks transit and weather at the same time. The response is small, versioned JSON about what the data means. It also says when to ask again, so a device never has to guess.
Adding something new
The platform exists to make the next project cheap.
- A new domain is one command,
pnpm new:domain <name>. It creates the Worker, a contract file, the scripts and the generated types. - A new project is one command,
pnpm new:project <name> --uses <domains>. It binds the project to the domains it uses and creates a versioned snapshot contract. - Exposing a project to clients is a deliberate, printed set of steps. Add a gateway route, grant clients in the manifest and run the sync. The scaffold never edits the gateway for me.
- A new client is a manifest entry plus one secret.
Problems worth remembering
Certificates change under a device. The first firmware pinned a single certificate authority root, and Cloudflare can serve certificates from several. A rotation would have taken every screen offline until I reflashed it over USB. The firmware now trusts the roots of every authority Cloudflare may use, a test checks each fingerprint, and screens can be updated over the home Wi-Fi.
Two repositories drifted. A review of the whole system found the firmware hard-coding a schedule the platform already knew better, and fixtures copied by hand into both repositories. Merging them and sharing the fixtures fixed the cause, not only the symptoms.
Land the work in progress before you restructure. The plan I agreed for tidying the platform starts with finishing and deploying half-done work. Merging repositories while two branches are unfinished is how work gets lost.
Local development has sharp edges. The local Workers runtime lags production's compatibility date, two dev servers sharing one local database lock each other's SQLite, and wireless firmware updates need Local Network permission for the terminal on macOS. Without it the error message misleads.
What's next
- A device scaffold, once a second device exists. Today's configuration assumes one.
- Promoting the household calendar to its own domain once a third app needs it.
- More of what the platform was built for. AI, scraping, collectibles, a family dashboard, a statistics board and an LED matrix.
- Serving the same API to apps, agents and scripts, with the same rules. Every client is listed, every response is versioned and private data stays behind authentication.