An app is notes. Not localhost:5173.

Local Reef is a desktop for the small apps you build on your own machine. Click an icon and the app opens in a window. The shell starts the server, assigns the port and never mentions it again.

Read the source

MIT licensed · macOS today, Electron underneath · 276 tests · early software built in the open

The Local Reef desktop: a painted coral reef with a row of glass bubble icons floating along the bottom edge.
Plate 01. The canvas, three apps docked. Icons are bubbles tinted from the app id, so a mixed set still reads as one family.

Forty apps means forty islands.

People build a lot of small local things now. Each one is a folder that needs a server started, a port remembered and a tab kept alive. The index lives in your head, and it rots.

Local Reef's premise is that the shell is the address bar. Everything in the architecture follows from making that true without the app having to know or care.

  • localhost:5173notes
  • localhost:3000clock
  • localhost:8000underscore
  • localhost:51823ledger

Three ways an app gets here.

02

Drag in a project you already have.

Nothing is copied. The folder stays where it is, so editing it in your editor edits what the desktop runs. Most projects need no configuration at all. One that does, because its own entrypoint hardcodes a port, needs three lines:

{
  "name": "Underscore",
  "type": "server",
  "run": "uv run uvicorn app:create_app --port $PORT"
}

run is a shell command with $PORT expanded, so any language works. Python, Go, a compiled binary. There is no Node assumption anywhere in the runtime.

03

Press ⌘K and describe one.

Claude writes the app through a toolset confined to a single folder. Paths that try to escape it are refused, and a run that fails or is declined removes the folder rather than leaving a broken icon behind.

⌘K → "a pomodoro timer with session history"

   wrote index.html
   wrote reef.json
   Built pomodoro-timer-session      🍅 icon appears, opens

Roughly two minutes. Output is a single self-contained index.html with no build step and no install, which is why it opens the moment it exists.

04

Drop a folder into apps/.

There is usually nothing to configure. An index.html and no package.json is a static app, served straight off disk with no process at all. A package.json carrying a dev or start script is spawned and proxied. A manifest exists only to override something.

index.htmlstatic, zero processes
package.jsonspawned and proxied
reef.jsononly to override

One gateway, routed on hostname.

Every app sits behind a single loopback HTTP server. Static apps are served off disk, server apps are proxied to the port their process actually took. Same address either way, and the app folder still runs standalone outside Local Reef.

iframe notes.reef.localhost iframe clock.reef.localhost x-reef-token x-reef-token Local Reef gateway 127.0.0.1 : ephemeral route on hostname strip the token serve, or proxy relay the upgrade one origin per app files on disk no process, instant spawned process 127.0.0.1:51823  + ws
Plate 05. The port exists. It never surfaces.

Not a custom protocol

app://notes/ was the original design and it does not work. Chromium custom schemes cannot carry a WebSocket, and a WebSocket is how hot reload works. An HTTP server on *.reef.localhost keeps HMR alive and buys per-app origins, so storage is partitioned by the browser rather than by anything we wrote.

Secure context, no certificate

Chromium treats .localhost as trustworthy, so apps get crypto.subtle, service workers and everything else gated on HTTPS. Resolution is pinned with host-resolver-rules rather than trusting the system resolver to honour RFC 6761.

A header, not a cookie

Any local process can forge a Host header, so requests carry a per-launch token. It has to be a header: an app frame is cross-site relative to the shell, and a SameSite=Lax cookie is stored and then never sent back.

A Clock app open in a Local Reef window over the reef wallpaper, showing live over websocket, through the gateway.
Plate 06. The clock sample hand-rolls a WebSocket on purpose, so the proxy and the upgrade relay are provable with no npm install.

Field notes.

Things that only appear once you build the thing. They are written down in DESIGN.md because several of these decisions exist purely because the obvious approach was tried and did not survive.

  1. resolver

    Servers may bind IPv6 loopback only.

    listen(port, 'localhost') binds whichever family the resolver returns first, and on modern macOS that is ::1. Vite does exactly this. Readiness probes both families, records which one answered, and the gateway proxies to that host. Miss the last step and a perfectly healthy app 502s.

  2. chrome match patterns

    A * scheme means http or https and nothing else.

    So *://*.reef.localhost/* never matched a WebSocket handshake. Every upgrade reached the gateway with no credential and was destroyed. HMR was dead in the real app while the Vite test stayed green, because that harness sets the header itself. A Node client is not a browser.

  3. http

    Upgraded sockets escape closeAllConnections().

    After an upgrade Node detaches the socket from the server's tracked list, but getConnections() still counts it, so server.close() waits forever. The gateway tracks upgraded sockets in its own set.

  4. macos

    A GUI launch inherits no shell environment.

    No PATH, so node and uv are simply missing, and no ANTHROPIC_API_KEY either. The supervisor resolves PATH once from a login shell and caches it, and the key gets a home in Settings. Both are consequences of the same fact.

Assert the effect a user sees, not the mechanism you just wrote.

Minimize shipped broken because its test asserted el.hidden === true, the state the code sets, while display: flex kept the window fully on screen.

Come and build on it.

Milestones one through four are done: registry, supervisor, gateway, canvas, ⌘K generation and Fix with AI. What is left is written down honestly in TODO.md, including the claims nobody has proven yet. That file is the first place to look.

Running it takes two commands.

# macOS, Node 20+
git clone https://github.com/etdebruin/localreef
cd localreef && npm install
npm start

# then, before you open a pull request
npm test   # 276 unit and integration tests
npm run lint

Launch from a terminal if you want ⌘K to work. npm start inherits the shell environment; the Dock does not.

Two things worth knowing first.

Read DESIGN.md before touching the gateway, the supervisor or the window model. Several decisions in there look arbitrary and are not.

The Electron suites are not duplication. They cover what the plain suite structurally cannot. npm test sets the Cookie header directly and bypasses browser policy, which is exactly how a bug shipped where every app frame returned a 401. UI tests drive real input events, because a programmatic click skips the pointer sequence and passes happily while a button is broken.

Open, and genuinely wanted.

  • good first

    keepAlive is parsed but not enforced.

    Closing a window stops a server app immediately. The warm-hold TTL in the manifest currently does nothing.

  • good first

    Window layout is not persisted.

    Position, size and minimized state are lost on relaunch, though the design calls for storing them. Your desk should look the way you left it.

  • meaty

    Apps are served without their own CSP.

    The shell is locked down; the app frames are not, so a generated app can still load remote code. Worth closing before generated apps become routine.

  • meaty

    No screenshot runs in CI.

    npm run shot photographs the running app over the DevTools protocol, and it has already caught two shipped bugs. Nothing runs it automatically, so a visual regression still needs someone to remember.

  • the big one

    The reef.* bridge.

    reef.storage, reef.window, and the interesting one, reef.ai: generated apps calling a model without the user pasting a key into each app. Designed in DESIGN.md §7, no code yet.

  • the big one

    Intents, and the shared store.

    Apps declare what they handle. "Save this to my reading list" finds whichever app claims add:link. This is the part a folder of separate localhost apps can never replicate, and the long-term reason this is a shell rather than a launcher.