unfolder.space

You are working with unfolder.space — a Cloudflare-hosted platform where each "space" is a self-contained website backed by:

  • a per-space Workspace (SQLite + R2-backed virtual filesystem) — a plain file store, not a repo
  • one live facet (the published Worker that serves the site)

Model

One space = one site. Cross-space sharing is over HTTP. Every space has a unique slug — the subdomain at <your-space-slug>.unfolder.space. All tools that operate on a specific space take slug as an argument.

Spaces can also be served from a custom domain (e.g. mydomain.com) by pointing a CNAME at self.unfolder.space and registering it via set_custom_domain. See custom-domain.

Start by calling get_urls(slug) — it returns every URL you need in one shot: the live site, the dashboard, the owner settings page, the browser upload page, the mcp endpoint, and a freshly-minted sessionUpload (a bearer-token PUT URL for streaming binary files straight to storage).

The file/sandbox surface

There are two ways to author files:

  • run_code — executes an async JS arrow expression in a sandbox where state.*, host.*, config.*, media.*, introspect.*, and place.* are available. This is the workhorse: read-then-write, build/publish, config, anything mixing logic with edits. Full method reference: run-code.
  • write_files — a plain text drop ({ path, content }[]), returning { ok, written }. No JavaScript is evaluated, so literal ${…} and backticks are preserved verbatim. Reach for it when authoring templates/source containing literal ${…}.

Binary belongs in media, never the source workspace. Images, fonts, video, audio, and any other binary are media — addressed by URL, not part of the source. Don't base64 them into write_files/run_code, and don't write them to the workspace (state.writeFileBytes, ?dest=workspace). Instead: media.write(…) in run_code, or get_urls(slug) → PUT the bytes to sessionUpload.uploadUrl?dest=media (it ships ready-to-run curl examples), or point the user at the browser upload page. The workspace is for text source (code, templates, config, markdown); media serves at the site root too (/favicon.ico, /og.png), so binary never needs to live in the source. See media.

Inside the run_code sandbox these namespaces are available:

  • state.* — the @cloudflare/shell Workspace file API: readFile, readFileBytes, writeFile, writeFileBytes, appendFile, mkdir, rm, cp, mv, readdir, readdirWithFileTypes, glob, stat, lstat, exists, diff, hashFile, searchText, searchFiles, replaceInFile, replaceInFiles, find, walkTree, summarizeTree, createArchive, extractArchive, readJson, writeJson, queryJson, updateJson, planEdits, applyEdits.
  • host.* — build & publish the space: build() (dry-run validate), publish() (go live). See bundling.
  • config.* — per-space configuration stored on the authoring DO (UnfolderWorkspace): getAssetConfig/setAssetConfig (asset headers/redirects — see bundling), plus Variables & Secrets and outbound access: setVariable (non-secret config in env), declareSecret (the owner sets the value in settings, never here), allowEgress (per-host outbound — outbound is default-deny), list/remove — full model: secrets.
  • media.* — durable uploaded/static assets served by URL: list, read, write, remove, url (see media).
  • introspect.* — observe & drive the live deployed space (not the source Workspace): query({ sql, params? }) runs a single SELECT/PRAGMA/EXPLAIN (no writes) against the running worker's DB (see the gotcha on runtimes below); queryLogs() reads recent runtime logs; fetch(path) GETs a served page through the real serving stack — including private /__ pages, no token needed; call({ method, args? }) invokes an opt-in __-prefixed hook the deployed App exposes (e.g. call({ method: "__reindex" })) — see run-code.
  • place.* — curate the places the owner sees in the Workspace dashboard: a tree of live frames into the site's own URLs. list() (nested tree), add(path, label?) (idempotent — re-adding updates the label), remove(path). A /__-prefixed place is private (owner-gated); any other path is a public page. Add a place right after you publish a page so it's showcased to the owner the moment it ships — see run-code.

The workspace is a plain file store: the working tree is the source, and one space has one live facet. Whatever your function returns is JSON-serialized back as the tool result.

Minimal end-to-end — write an index.html and go live:

// run_code({ slug, code: ... })
async () => {
  await state.writeFile("/index.html", "<h1>hello from unfolder</h1>");
  // Goes live on the single facet at https://<your-space-slug>.unfolder.space
  return await host.publish();
}

Build & publish

Build and publish run inside run_code as host.*, so you edit and ship in one call (state.writeFilehost.publish()).

  • host.build() — bundles the working tree into a Worker as a dry-run: it surfaces bundler warnings/errors without going live, returning { snapshotHash, fromCache, assetCount, warnings }. Optional — host.publish() always builds, so a standalone host.build() is only for checking.
  • host.publish() — builds + goes live on the single facet (<your-space-slug>.unfolder.space), returning { ok, status, live, snapshotHash, instanceId, url?, visibility? }url + visibility: "public" appear only once live is true (a still-building deploy reports status: "running", no URL; a failure carries error instead). The snapshotHash is the content identity of the build (a pure function of source files + asset config). The live facet preserves its DO SQLite across publishes, and auto-reprimes from the last published artifact on cold start.

There is one live facet per space. Iterate with state.* + host.build() until the build is clean, then publish.

Platform gotchas (read before your first build)

These are the non-obvious edges that most often cost a build cycle. None are fatal; knowing them up front saves the build-and-probe loop.

  • Declare every npm dependency in package.json — this is supported and preferred. The bundler resolves declared packages from the npm registry; a bare import (e.g. import { Hono } from "hono") that is not in package.json is silently left external and the worker throws No such module "hono" at runtime — a clean build, a 500 at request time. hono + hono/jsx is the recommended stack for facet backends; add it to dependencies and import normally.

  • The entry file must be .ts or .js. Entry detection looks for, in order: src/index.ts, src/index.js, src/index.mts, src/index.mjs, index.ts, index.js, src/worker.ts, src/worker.jsnot .tsx/.jsx. .tsx/.jsx are fully supported as modules (import them freely for JSX views); they just can't be the entry. A JSX entry needs a tiny src/index.ts shim that re-exports the named class: export { App } from "./app.tsx"; (keep the DO export named App, never default).

  • /__* paths are owner-gated at the edge. Every request path starting with /__ is intercepted by the platform's manage gate before your worker runs: owners/admins pass (the dashboard frames a gated place with an invisible auto-minted token; a cookie follows), everyone else gets a plain 404 — not a routing bug in your code. Build owner-only admin surfaces under /__ (the gate makes hand-rolled auth unnecessary for owner-only pages), register them with place.add so the owner can actually reach them, and verify them with introspect.fetch (which enters below the gate).

  • Media shadows the worker. Serving precedence is bundled assets → media bucket → worker. A file at media/index.html (or any media key colliding with a route) is served ahead of the worker at that path, silently — the worker's matching route never runs. Remove the media file or serve that path from the worker.

  • The run_code sandbox is a different runtime from the live worker. state.*/config.*/media.* operate on the source Workspace, not the live facet. The live worker's DO SQLite is only reachable at request time, inside the worker. To read it, use introspect.query (read-only) in run_code. To write/seed it, go through the facet's own HTTP API (an endpoint your worker exposes) — you cannot mutate live SQLite from the sandbox. (introspect requires the published App to expose a __query hook; the starter scaffold includes it.)

  • config.allowEgress / secret values need owner approval. New egress entries start pending: true and outbound fetch stays blocked (403) until the owner approves the host on the settings page — even though the call returns { ok: true }. Likewise declareSecret only names a secret; the owner pastes its value in settings. You can never approve a host or set a secret value from here (that's the anti-exfiltration boundary). The same gateway also governs fetch inside run_code itself — a sandbox fetch to an unapproved host gets the same synthetic 403.

For deeper guidance, read the docs resources (start with workflow, run-code, bundling, dynamic-worker, media). To build a dynamic site, start the unfolder.routing pattern (or unfolder.static-site for a files-only site); to gate a space by identity, pick one of the unfolder.auth-* patterns.