Search

Sign in to launch Copilot/Codex from the palette.

DESK AND LIVING-ARTIFACT

git push to install apps. signed releases to update firmware. one Worker behind both.

Hardware 2026.05.15
desk running on an M5StickC Plus

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

ThingWhere it lives
App storegit repo
Installgit push
Updatefabric reads HEAD on next call
Rollbackgit revert
Audit loggit log
App formatJS body inside a Markdown manifest
App runtimeWorker Loader isolate, no egress
App stateDO Facet, one SQLite per app
ClientsM5 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 -> frame

Frames

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.

ToolEffect on the device
desk.asktakes over the screen with a question, waits for A/B, returns the choice.
desk.inboxqueues a notification. color is severity. dismiss with A.
desk.observesets 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 showing GIT PUSH -> DEVICE, HEALTH OK

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

ProjectWhat changesHow
deskapps on the devicegit push to Artifacts
living-artifactfirmware on the devicesigned release + A/B OTA + rollback

same orange shell. same Cloudflare account. different layer is liquid.