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 where state.*, git.*, config.*, media.*, and introspect.* 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/shell Workspace 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 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.* — probe the deployed facet (not the source Workspace): query({ sql, params?, facet? }) runs a single SELECT/PRAGMA/EXPLAIN (no writes) against the running worker's DB (facet defaults to production); adminFetch(input, init?, facet?) (the standard new Request args) 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.addgit.commithost.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 standalone host.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 with git.branch({ list: true }), which returns { branches, current }current is your deploy target.
    • branch mainproduction facet (<your-space-slug>.unfolder.space)
    • any other branch → a preview facet, served at <your-space-slug>--{previewId}--preview.unfolder.space The previewId is a generated id, not the branch name (stable per branch across redeploys). Read the exact preview URL from the host.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 from main on cold start.

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 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 src/index.ts, index.ts, src/worker.ts (and .js/.mts/.mjs) — not .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 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_code sandbox 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, 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 deployed 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).

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.