run_code API reference
run_code({ slug, code }) evaluates an async arrow function in a sandbox
attached to the space's Workspace, and JSON-serializes whatever you return. The
tool's response is an envelope { result, logs } — result is your function's
return value, logs collects console.* output; a thrown error yields
{ result: undefined, error } instead. (Other tools like write_files and
get_urls return their object directly, unwrapped.)
run_code({ slug, code: `async () => {
await state.writeFile("/index.html", "<h1>hi</h1>");
return await state.glob("**/*");
}` })
Six namespaces are in scope: state.* (files), host.* (build/publish),
config.* (asset config + variables/secrets/egress), media.* (durable
assets), introspect.* (observe & drive the LIVE deployed space — SQL, logs,
page fetches, __-hook calls), and place.* (frames into your own pages the
owner sees in the Workspace dashboard). The workspace is a plain file store:
the working tree is the source. Arguments are positional, exactly as shown
below.
Escaping:
codeis a JS expression, so a literal${…}in source you author is interpolated at parse time. Escape it as\${…}, useString.raw, or — simpler — drop verbatim source with thewrite_filestool instead.
Outbound
fetchfrom the sandbox goes through the same per-space egress gateway as your deployed site: default-deny, own host always allowed. A host you haven'tconfig.allowEgress'd (and had the owner approve) answers with a synthetic 403, not the real upstream response.
state.* — Workspace files
Paths are absolute (/src/index.ts). readFile/readFileBytes throw ENOENT
if missing; stat/lstat return null for a missing path; exists never
throws; glob returns sorted absolute paths.
Read & inspect
await state.readFile("/src/index.ts") // → string
await state.readFileBytes("/logo.png") // → Uint8Array
await state.exists("/package.json") // → boolean
await state.stat("/src") // → { type, size, mtime, … } or null if missing (follows symlinks)
await state.lstat("/link") // → stat without following the final symlink
await state.readdir("/src") // → string[] (names)
await state.readdirWithFileTypes("/src") // → [{ name, type }] (type: "file" | "directory" | "symlink")
await state.glob("src/**/*.{ts,tsx}") // → string[] absolute paths
await state.walkTree("/", { maxDepth: 3 }) // → nested tree node
await state.summarizeTree("/") // → { files, directories, symlinks, totalBytes, maxDepth }
await state.find("/", { type: "file" }) // → [{ path, … }] flat listing
await state.diff("/a.txt", "/b.txt") // → unified diff string
await state.hashFile("/dist/app.js") // → content hash string
Write & mutate
await state.writeFile("/index.html", "<h1>hi</h1>")
// Advisory: writeFileBytes is for text you happen to hold as bytes — NEVER media.
// Images/fonts/video/audio go to media (media.write / get_urls dest=media),
// not the source workspace.
await state.writeFileBytes("/data/seed.csv", bytes) // bytes: Uint8Array
await state.appendFile("/log.txt", "line\n")
await state.mkdir("/assets", { recursive: true })
await state.rm("/tmp", { recursive: true })
await state.cp("/a.txt", "/b.txt")
await state.mv("/old.txt", "/new.txt")
Search & bulk edit
await state.searchText("/src/app.ts", "TODO") // ONE file → [{ line, column, match, lineText }]
await state.searchFiles("src/**/*.ts", "useState") // glob-wide → [{ path, matches: [{ line, column, match, lineText }] }]
await state.replaceInFile("/src/app.ts", "v1", "v2") // single file
await state.replaceInFiles("src/**/*.ts", "v1", "v2") // glob-wide
await state.planEdits([{ /* instruction */ }]) // → an edit plan (preview)
await state.applyEdits([{ /* edit */ }]) // apply edits atomically
JSON helpers
await state.readJson("/data.json") // → parsed value
await state.writeJson("/data.json", { a: 1 })
await state.queryJson("/data.json", ".items[0].name") // dot-path query (no "$" root sigil)
await state.updateJson("/data.json", [{ /* op */ }]) // structured update
Archives
await state.createArchive("/out.tar", ["/src", "/public"]) // tar archive (not zip)
await state.extractArchive("/in.tar", "/unpacked")
A few niche helpers exist too, called the same way: symlink, readlink,
realpath, resolvePath, diffContent, listArchive, compressFile,
decompressFile, detectFile, removeTree, copyTree, moveTree,
applyEditPlan.
host.* — build & publish
The working tree is the source: edit files with state.*, check the build
with host.build(), and go live with host.publish(). One space has one live
facet.
await host.build() // bundle the working tree as a dry-run → { snapshotHash, fromCache, assetCount, warnings }; validate only, no go-live
await host.publish() // → { ok, status: "complete"|"running", live, snapshotHash, instanceId, url?, visibility? } — url/visibility only once live is true;
// on failure → { ok: false, status: "errored"|"terminated", error, snapshotHash, instanceId }
build() is a pre-flight that surfaces bundler warnings/errors without
publishing; publish() always builds first, so a standalone build() is only
for checking. The snapshotHash is the content identity of the build (a
pure function of the source files + asset config). Edit and publish in one call:
async () => {
await state.writeFile("/src/index.ts", code);
return await host.publish();
}
The published facet preserves its DO SQLite across publishes. Custom domains are
separate top-level tools (set_custom_domain / get_custom_domain /
remove_custom_domain), not part of host.*.
config.* — asset config, variables, secrets, egress
await config.getAssetConfig() // → AssetConfig | null
await config.setAssetConfig({ /* headers, redirects */ }) // see bundling
await config.setVariable({ name: "API_BASE_URL", value: "https://api.example.com" })
await config.declareSecret({ name: "STRIPE_KEY" }) // name only → { ok, pending: true, settingsUrl } — owner sets the value in settings
await config.allowEgress({ host: "api.github.com" }) // → { ok, settingsUrl } — owner approves in settings
await config.list() // → { variables, egress } — never secret VALUES
await config.remove({ name: "API_BASE_URL" }) // by variable/secret NAME, or rule by HOST → { ok, existed, deletedFrom }
A rejected call resolves with { ok: false, code: "INVALID", error } (bad name,
reserved MEDIA) — it does not throw. Secrets appear inside the variables
array of config.list() with secret: true (a declared-but-unset secret also
carries pending: true); there is no separate secrets key.
Secret values and host approvals are owner-only, done on the settings page
(get_urls returns the settings URL). Secrets and egress are independent — a
secret is injected into env (owner sets the value); outbound hosts are a separate
allow/deny list. Full model: secrets and
bundling.
media.* — durable assets served by URL
await media.list() // → [{ path, size, uploaded, contentType }]
await media.list("photos/") // filter by prefix
await media.read("notes.txt") // → text, or null
await media.write("data/site.json", "{}", "application/json") // → { ok, path }; code-like text (html/css/js) is rejected — that belongs in the workspace
await media.remove("old.png")
await media.url("photos/hero.jpg") // public → https://<your-space-slug>.<domain>/photos/hero.jpg
await media.url("_private/deck.pdf", { expiresIn: 600 }) // private (`_*`) → signed, time-limited URL
For binary uploads, don't pass bytes here — call get_urls for a
sessionUpload URL and PUT out-of-band. Precedence and private (_*) media:
media.
introspect.* — observe & drive the live space
Everything here targets the deployed space, never the source Workspace:
read its SQLite, its logs, fetch its served pages (including private /__
ones), and call its opt-in __-hooks.
Read live SQLite
await introspect.query({ sql: "SELECT id, body FROM notes ORDER BY id" })
await introspect.query({ sql: "SELECT * FROM notes WHERE id = ?", params: [1] })
await introspect.query({ sql: "PRAGMA table_list" })
Read-only (a single SELECT/PRAGMA/EXPLAIN) against the live facet's
runtime DB — this is the only way to read it from run_code, since state.* is
the source Workspace, not the live worker. Writes go through your worker's HTTP
API. Requires the published App to keep the scaffold's __query export — if
it's missing (or returns the wrong shape) you get { ok: false, code: "UNSUPPORTED", error }; the fix is in admin-prep.
Background: dynamic-worker.
Read recent logs
await introspect.queryLogs() // last hour, newest-first
await introspect.queryLogs({ level: "error", limit: 50 }) // errors only
await introspect.queryLogs({ since: Date.now() - 86_400_000 }) // last 24h
The space's recent runtime logs, newest-first. Every entry carries a source that
tells you WHERE it came from:
source: "app"— your deployed worker's ownconsole.*and uncaught exceptions: your code, running in production. This is what to read when your site is up but behaving wrong.source: "platform"— the hosting runtime's own errors for this space (e.g. "noAppexport", "no R2 artifact", a failed prime, a 5xx serve). This is what to read when the site won't serve at all.
Use it to debug a space that deployed but misbehaves or won't serve. Returns
{ ok: true, entries: [{ ts, level, kind, source, message }] }.
Not realtime — backed by Workers Logs, so entries land within seconds and are kept
for that platform's retention window (a few days), not forever. { ok: false, code: "UNAVAILABLE" } means logs aren't wired up for this environment (e.g. local dev).
Fetch a served page — public OR private
await introspect.fetch("/about") // → { status, contentType, body }
await introspect.fetch("/__manage") // gated pages serve too — no token needed
await introspect.fetch({ path: "/data.json" })
A GET of your live site through the real serving stack (static tier →
your deployed worker), returning { status, contentType, body, truncated?, location? }. This is how you SEE the served result of a page — what curl
would show — including private /__ pages the public edge answers 404
for: the request enters below the gate, and you're already authorized as the
space's agent, so no token is ever minted or exposed.
Redirects come back raw (status 30x + location), never followed. A binary
answer (an image, a font) omits body and reports { bytes } instead — read
binary through media.*. Bodies over 256 KB are truncated (truncated: true).
Method is always GET — to exercise POST routes, call your own hooks (below) or
use outbound fetch against your public URL.
Call your App's own __-hooks
await introspect.call({ method: "__reindex", args: [] })
// → { ok: true, facet: "production", result: ... }
// → { ok: false, code: "NOT_DEPLOYED" | "UNSUPPORTED" | "CALL_ERROR", error: "..." }
The generic dispatch behind introspect.query (__query), generalized: only
__-prefixed hooks are callable, so an author can drive their OWN opt-in hooks
(__reindex, __migrate, …) the same way, without a bespoke platform verb for
each one. __query stays the sugar for the platform's own convention; reach
for introspect.call for anything else your App chooses to expose.
NOT_DEPLOYED means publish first — there's no live facet to call. UNSUPPORTED
means the method isn't there (no such __ hook) or method isn't __-prefixed
(the guard rejects it outright — it will never dispatch to a facet's fetch or
any other non-__ method). CALL_ERROR means the hook ran and threw.
Trust model: the hook runs in the live facet against its real runtime
state — it can read/write whatever the deployed code's own logic touches, and
nothing more; introspect.call grants no new power beyond what the App already
chose to expose.
place.* — frames into your own pages
A place is a saved pointer to one of your site's own URLs. The owner sees
your places as a TREE of frames in the Workspace dashboard and clicks to preview
each one live. Curate them so a page you just built is showcased the moment
it ships — add /gallery right after you publish it, and it appears for the
owner with no work on their part. Places are yours to manage: the dashboard
has no add/remove UI, only this API.
await place.list()
// → [{ path, label?, createdAt?, implicit?, children: [...] }] (nested, sorted by path)
await place.add("/gallery", "Gallery") // positional: path, optional label
await place.add({ path: "/__manage", label: "Manage" }) // or object form
// → { ok: true, place: { path, label?, createdAt } }
// → { ok: false, code: "INVALID", error: "..." } (bad path — see rules below)
await place.remove("/gallery") // → { ok: true } (false if it wasn't there)
The path segments ARE the tree address — grow a deep place directly, no
pre-creation needed: place.add("/__design/layout") alone gets you a /__design
branch node in list() with /__design/layout nested under it, even though
/__design was never added. That implied branch carries implicit: true and
no label/createdAt — until you also place.add("/__design", "Design"),
at which point the same node picks up real metadata and keeps its children: a
node can be both a place (selectable, has its own label) and a parent (has
children) at once. place.remove("/__design") removes just that node's own
metadata — it degrades back to an implicit branch; any stored descendants
(/__design/layout) are untouched and the tree stays well-formed, no orphan
cleanup needed.
Path rules: must start with /, ≤ 512 chars, no whitespace / // / ? / #
/ .. segments; a trailing slash is stripped (except root /). Re-adding the
same path is idempotent (updates the label, keeps the original createdAt).
Public vs private: a place under a /__-prefixed path (e.g. /__manage,
/__admin) is private — the platform gates it so only the space's owners and
admins can open it (the dashboard frames it with an invisible, auto-minted
token). Any other path (e.g. /gallery, /rsvp) is a normal public page. Add
whichever kind fits — a public showcase, or an owner-only admin surface you
built behind /__.
Borrowing the platform's media picker from a place
A place you build (e.g. /__manage/posts/new) renders inside an iframe on the
Workspace dashboard — a DIFFERENT origin from the dashboard itself (your site's
subdomain vs the apex admin). Rather than build your own upload/browse UI for
"pick a cover image", your page can ask the dashboard to open the platform's
own media picker on its behalf, over window.postMessage:
// The dashboard lives on the APEX host; your page runs on your subdomain. A
// postMessage targetOrigin must name the RECIPIENT's origin — the apex — which
// is structurally your own host minus its first label (works across envs).
const dashboardOrigin = `${location.protocol}//${location.host.replace(/^[^.]+\./, "")}`;
// Your page → the dashboard (window.parent), asking it to open the picker.
window.parent.postMessage(
{
source: "unfolder-app",
kind: "pick-media",
field: "cover", // any name YOU choose — echoed back unchanged
visibility: "public", // optional: "public" (default) | "private" | "any"
accept: "image/*", // optional: same grammar as <input accept>
multiple: false, // optional: multi-select (checkbox) vs single (radio)
},
dashboardOrigin, // the dashboard's origin as targetOrigin — never "*"
);
// The dashboard replies with the chosen path(s) once the owner confirms.
window.addEventListener("message", (e) => {
if (e.origin !== dashboardOrigin) return; // only the dashboard's origin
if (e.data?.source !== "unfolder-host" || e.data?.kind !== "media-picked") return;
if (e.data.field !== "cover") return; // ignore replies for a different field
console.log(e.data.items); // → string[] of picked media paths, e.g. ["hero.png"]
});
No file bytes ever cross the boundary — items is plain media path strings
(the same paths media.* and the public media URL use). Nothing dispatches
unless the owner explicitly confirms the picker's "Select" button; dismissing
it sends no message at all.