DESK AND LIVING-ARTIFACT
git push to install apps. signed releases to update firmware. one Worker behind both.

M5StickC Plus 1.1. dock: inbox, counter, pet, tunes.
The Problem
Problem: I wanted apps and firmware on a small device without cables, dashboards, or a store.
Solution: A Worker reads apps from a Cloudflare Artifacts git repo, runs each one in a Worker Loader isolate with a per-app DO Facet, and ships frames over HTTPS. A second project does the same shape one layer down, for firmware.
git push -> apps repo (Cloudflare Artifacts) -> desk fabric Worker -> Worker Loader isolate (per-app, no egress) -> frame JSON -> M5 screen
What's There
| Thing | Where it lives |
|---|---|
| App store | git repo |
| Install | git push |
| Update | fabric reads HEAD on next call |
| Rollback | git revert |
| Audit log | git log |
| App format | JS body inside a Markdown manifest |
| App runtime | Worker Loader isolate, no egress |
| App state | DO Facet, one SQLite per app |
| Clients | M5 polls. browser polls. agent calls MCP. |
every right column is already a Cloudflare primitive.
The Dock
A new app starts as a single Markdown file. YAML manifest, JS body. Push it to the apps repo. The M5 polls /list every ~10s. The browser viewer polls every ~2s. Within that window the app shows up in the dock.
The Tamagotchi went in this way. So did the counter, the chiptune player, the inbox surface, and a dice roller I made at dinner.
apps/dice/manifest.md
manifest (YAML frontmatter)
body (ES module)
init() -> frame
onInput -> frameFrames
Apps don't draw. They return JSON.
return {
f: count,
ops: [
["clr", "black"],
["bnr", "COUNTER", "orange"],
["txt", 4, 80, String(count), "white", true],
],
};Same frame renders on the M5's 135x240 ST7789 LCD and on a 14KB browser page. The op vocabulary is small enough that any surface that can read JSON and paint pixels can be a desk.
MCP Tools
The same Worker runs an MCP server at /mcp. Three tools matter so far.
| Tool | Effect on the device |
|---|---|
| desk.ask | takes over the screen with a question, waits for A/B, returns the choice. |
| desk.inbox | queues a notification. color is severity. dismiss with A. |
| desk.observe | sets an ambient banner: what an agent is currently doing. |
Measured on the deployed Worker: agent fires desk.ask, the M5 takes over within ~10s (bounded by the dock-refresh poll cadence), user presses A, the choice returns to the agent. ~17s total round-trip. Most of that is the device poll, not Cloudflare.
How Apps Run
Each app is loaded into its own Worker Loader isolate. Loader id is ${appId}:${version}:${sha8}, so any change to the file forces a fresh load even at the same semver.
The isolate has globalOutbound: null, a per-app capability set, and a CPU budget pulled from the manifest. State is a DO Facet, one SQLite per app, invisible to the others.
loader id = appId:version:sha8 globalOutbound: null cpu_ms_per_input from manifest per-app DO Facet (sqlite + kv) no cross-app reads
Notes From Getting It Working
- MicroPython 1.24 hits
abort()on this ESP32-PICO-D4 at WiFi init:"expected 10 rx buffers, actual 4". 1.22 works. Pinned. machine.reset()leaves the netif registered. Cold-reset via USB or RTS instead.- TLS handshake to the fabric needs ~30..40KB of contiguous heap. The bold 16x32 font fragmented enough to drop me under. Dropped the font, freed ~16KB, handshake stuck.
- DNS warmup is per-call. lwIP's cache drops entries during heavy I2C between WiFi-up and the first HTTP.
firmware notes, kept here so I don't forget.

living-artifact device. orange shell, same family. screen shows GIT PUSH -> DEVICE / HEALTH OK.
living-artifact
Same shape, one layer down. Not apps on top of firmware. The firmware itself is the thing that updates.
signed release manifest + firmware binary (Cloudflare R2, served by Worker) -> device fetches over Wi-Fi -> verifies SHA-256 -> writes inactive OTA slot -> reboots -> POSTs /devices/:id/health -> bootloader marks valid, or rolls back
- 4MB flash with A/B OTA partitions.
- Manifest signed with a release key on the Worker.
- SHA-256 verified before boot-slot switch.
- Health check required before the new firmware is marked valid.
Rollback
I pushed a build that crashes before it sends health. The device boots into the new slot, never marks it valid, and the bootloader returns to the previous valid slot on the next boot.
Bad bits don't advance stable. Bad firmware doesn't stick.
What Doesn't Work Yet
The dream for living-artifact is the same shape as desk: push to an Artifacts repo, Cloudflare builds the firmware Artifact in a Container, the release authority promotes it, the device picks it up.
The Worker and Container scaffolding exist. The current ESP-IDF builder image is over Cloudflare's Container image-size limit, so the source-to-device loop isn't proven end-to-end. The safety loop is. That's the half I'd write a different post about once it's real.
What Changes On Each
| Project | What changes | How |
|---|---|---|
| desk | apps on the device | git push to Artifacts |
| living-artifact | firmware on the device | signed release + A/B OTA + rollback |
same orange shell. same Cloudflare account. different layer is liquid.