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 git repository that lives inside that workspace
- one or more deployed facets (production + per-branch previews)
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.*,git.*,config.*,media.*, andintrospect.*are available. This is the workhorse: read-then-write, git, config, anything mixing logic with edits. Full method reference: run-code.write_files— a plain text drop ({ path, content }[]). 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 git workspace. Images, fonts, video,
audio, and any other binary are media — addressed by URL, not committed to
git. 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 git. 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,searchText,searchFiles,replaceInFile,replaceInFiles,find,walkTree,summarizeTree,createArchive,extractArchive,readJson,writeJson,queryJson,updateJson,planEdits,applyEdits.git.*— the same Workspace as a git repository:init,clone,status,add,rm,commit,merge,log,branch,checkout,fetch,pull,push,diff,remote.config.*— per-space configuration stored on the supervisor: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.*— probe the deployed facet (not the source Workspace):query({ sql, params?, facet? })runs a singleSELECT/PRAGMA/EXPLAIN(no writes) against the running worker's DB (facetdefaults to production);adminFetch(input, init?, facet?)(the standardnew Requestargs) self-tests your facet's custom admin view (__adminFetch) without an owner login — returns the response as{ status, headers, body }. See the gotcha on runtimes below.
Conventions: git ops take an optional dir (the repo root — defaults to /,
so you can omit it). git.add stages by filepath (singular), e.g.
git.add({ filepath: "." }). Whatever your function returns is
JSON-serialized back as the tool result.
Minimal end-to-end — write an index.html, commit, deploy:
// run_code({ slug, code: ... })
async () => {
await state.writeFile("/index.html", "<h1>hello from unfolder</h1>");
await git.add({ filepath: ".", dir: "/" });
await git.commit({
message: "initial",
author: { name: "Agent", email: "agent@unfolder.space" },
dir: "/",
});
// `main` is checked out by default, so this lands on production at
// https://<your-space-slug>.unfolder.space
return await host.deploy();
}
Build & deploy
Build and deploy run inside run_code as host.*, so you commit and ship in
one call (git.add → git.commit → host.deploy()).
host.build()— bundles the workspace at HEAD into a Worker. An optional pre-flight: it surfaces bundler warnings/errors without publishing.host.deploy()always builds, so a standalonehost.build()is only for checking. Both refuse on a dirty tree or no commits — commit first.host.deploy()— the current branch alone decides where the bundle lands. Check the current branch withgit.branch({ list: true }), which returns{ branches, current }—currentis your deploy target.- branch
main→productionfacet (<your-space-slug>.unfolder.space) - any other branch → a
previewfacet, served at<your-space-slug>--{previewId}--preview.unfolder.spaceThepreviewIdis a generated id, not the branch name (stable per branch across redeploys). Read the exact preview URL from thehost.deploy()/host.listPreviews()response — don't construct it from the branch name. Both production and preview URLs are publicly viewable. Production preserves SQLite across redeploys; previews reset SQLite per redeploy and are in-memory only (lost on DO restart, recoverable by redeploying). Production auto-reprimes frommainon cold start.
- branch
Previewing and shipping
The git API is JavaScript (run inside run_code), not a shell — there's no
git checkout -b. Create a branch, then check it out (creating doesn't switch):
async () => {
await git.branch({ name: "feat-x", dir: "/" }); // creates the branch
await git.checkout({ ref: "feat-x", dir: "/" }); // switches HEAD onto it
}
// edit + commit, then host.deploy() (in run_code) → use the preview URL from the response
To ship a previewed change to production, merge it into main and deploy.
git.merge is local (no remote needed):
async () => {
await git.checkout({ ref: "main", dir: "/" });
return await git.merge({
theirs: "feat-x",
author: { name: "Agent", email: "agent@unfolder.space" },
});
}
// then host.deploy() → production
Fast-forward and clean three-way merges succeed; conflicting edits abort
(resolve by redoing the change on main). git.fetch/pull/push are for
real remotes (e.g. GitHub) only — a space has no remote by default, so
they don't move commits between local branches; use git.merge for that.
Platform gotchas (read before your first build)
These are the non-obvious edges that most often cost a deploy cycle. None are fatal; knowing them up front saves the deploy-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 forsrc/index.ts,index.ts,src/worker.ts(and.js/.mts/.mjs) — 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 it:export { default } from "./app.tsx"; export * from "./app.tsx";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 deployed worker.state.*/git.*/config.*/media.*operate on the source Workspace, not the live facet. The deployed 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 deployedAppto 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).
For deeper guidance, read the docs resources (start with
workflow, run-code,
bundling, dynamic-worker,
media). To build a server-rendered site fast, start the
unfolder.hono prompt; to gate a space by identity, start the unfolder.auth prompt.