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: code is a JS expression, so a literal ${…} in source you author is interpolated at parse time. Escape it as \${…}, use String.raw, or — simpler — drop verbatim source with the write_files tool instead.

Outbound fetch from the sandbox goes through the same per-space egress gateway as your deployed site: default-deny, own host always allowed. A host you haven't config.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 own console.* 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. "no App export", "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.