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 wherestate.*,host.*,config.*,media.*,introspect.*, andplace.*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/shellWorkspace 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 inenv),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 singleSELECT/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.writeFile → host.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 standalonehost.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 onceliveis true (a still-building deploy reportsstatus: "running", no URL; a failure carrieserrorinstead). ThesnapshotHashis 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 inpackage.jsonis silently left external and the worker throwsNo such module "hono"at runtime — a clean build, a 500 at request time.hono+hono/jsxis the recommended stack for facet backends; add it todependenciesand import normally.The entry file must be
.tsor.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.js— not.tsx/.jsx..tsx/.jsxare fully supported as modules (import them freely for JSX views); they just can't be the entry. A JSX entry needs a tinysrc/index.tsshim that re-exports the named class:export { App } from "./app.tsx";(keep the DO export namedApp, 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 withplace.addso the owner can actually reach them, and verify them withintrospect.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_codesandbox 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, useintrospect.query(read-only) inrun_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. (introspectrequires the publishedAppto expose a__queryhook; the starter scaffold includes it.)config.allowEgress/ secret values need owner approval. New egress entries startpending: trueand outboundfetchstays blocked (403) until the owner approves the host on the settings page — even though the call returns{ ok: true }. LikewisedeclareSecretonly 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 governsfetchinsiderun_codeitself — 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.