Scope
This document covers the customer-code execution surface — what we call Functions. It's the implementation companion to the feature overview. If you're evaluating us for a production use case that needs customer-authored code, this is the page that answers the questions you'd ask in a security review.
What you'll find here:
- The graph data model:
Script,ScriptVersion,Capability,Runtime,Secret,ExecutionMode. - The wire format for
POST /api/scripts,PUT /api/scripts/:name, and the inbound-webhook receiver. - The
isolated-vmsecurity boundary — what's exposed, what isn't, and why. - The two execution modes and how they fire.
- Failure modes + observability.
Graph data model
Scripts are first-class graph entities. A script is a logical container; each deploy creates a new immutable ScriptVersion. Capabilities, secrets, and the runtime are edges, not properties — they're independently queryable and revocable.
(Site)-[:HAS_SCRIPT]->(Script {name})
(Script)-[:HAS_VERSION]->(ScriptVersion {version, code, createdAt})
(Script)-[:BELONGS_TO]->(Site)
(ScriptVersion)-[:RUNS_AS]->(ExecutionMode)
(ScriptVersion)-[:RUNS_ON]->(Runtime)
(ScriptVersion)-[:GRANTED]->(Capability)
(ScriptVersion)-[:USES_SECRET]->(Secret)
Every edge is recorded in the audit chain. Rotating a secret is a USES_SECRET edge swap. Deprecating a capability is dropping a GRANTED edge. Forensics on "when did this script gain access to X" is a single graph traversal.
ExecutionMode values today
em:build-transform— runs at compile time on content records. Default for new scripts.em:inbound-webhook— runs on HMAC-authenticated POSTs at/api/hooks/:siteId/:slug.
Other modes (em:on-request, em:scheduled) are planned. The mode is a graph node, not a string property, so future modes don't require a schema migration.
Runtime values today
rt:nodejs22— the default. V8 22.x viaisolated-vm.
Python (via Pyodide) and WebAssembly are on the roadmap. Adding a new runtime is creating a new Runtime node and wiring the appropriate isolation backend.
POST /api/scripts — create a script
POST /api/scripts
Authorization: Bearer <key>
X-Site-Id: site:<your-site>
Content-Type: application/json
{
"name": "enrich-blog-post",
"code": "const post = input; return { ...post, readMinutes: Math.ceil(post.body.split(/\\s+/).length / 230) };",
"runtime": "nodejs22",
"capabilities": ["graph.read"],
"secrets": []
}
201 Created
{
"scriptId": "script:enrich-blog-post",
"versionId": "sv:enrich-blog-post:1"
}
Required fields: name (kebab-case, max 64 chars) and code (the function body — see Execution model below for what's in scope inside the body).
Optional fields:
runtime— defaults tonodejs22.capabilities— array of capability names. Today:graph.read,http.outbound. The script can only call host helpers that match a granted capability.secrets— array of secret names. Each must already exist as aSecretnode (rotate / create separately via the/api/secretsendpoint).
PUT /api/scripts/:name — new version
PUT /api/scripts/enrich-blog-post
{
"code": "const post = input; return { ...post, readMinutes: Math.ceil(post.body.split(/\\s+/).length / 250) };"
}
200 OK
{ "versionId": "sv:enrich-blog-post:2", "version": 2 }
Each PUT creates a new ScriptVersion. The Script node points at the latest by version number; older versions remain in the graph. Rolling back is a single mutation that re-points the Script at an older version — the same primitive as content rollback.
The execution model: isolated-vm in detail
This is the part that matters for a security review.
Why isolated-vm and not node:vm
Node's built-in vm module shares the host heap. The official docs explicitly warn it is not a security boundary. Multiple published prototype-pollution and structured-clone escapes let JS in vm reach process.binding, the module loader, and the filesystem. We do not use node:vm.
isolated-vm creates a separate V8 isolate. The isolate has its own heap, its own globals, and no way to reference anything in the host process unless the host explicitly exposes it. Prototype tricks don't escape because there's nothing to escape to.
The host exposes capabilities via ivm.Reference
The isolate starts empty — no console, no input, no utils. The host then installs References for each capability. A Reference is a host-side function that the isolate can call but cannot inspect; the host enforces checks, then returns a structurally-cloned result via ExternalCopy.
// In the host (transform-runner.ts):
await jail.set('__console_log',
new ivm.Reference((...args) => logs.push({ level: 'log', args: args.map(cloneSafe) })),
);
// Inside the isolate, a bootstrap script wraps __console_log into
// a familiar "console.log" API the user code calls naturally.
The full set of References installed today:
| Reference | Host-side implementation | Capability gate |
|---|---|---|
__console_log / __console_warn / __console_error |
Append to logs[]; ExternalCopy strips functions/symbols | always-on |
__input |
Cloned via ExternalCopy from opts.input |
always-on |
__util_slugify, __util_iso, __util_now |
Pure helpers (no I/O); host-side functions | always-on |
__util_http_request |
node:undici fetch with 10s timeout + 1MB cap | http.outbound |
__graph_query |
graphClient.query (read-only — no MERGE/CREATE/DELETE/SET) | graph.read |
References installed but not gated by capabilities (console, input, utils.{slugify,iso,now}) are pure — they can't reach the network, the graph, or the filesystem. Capability-gated References check the GRANTED edge at script-load time. There is no "elevate at runtime" path.
The bootstrap script
After all References are installed, the host runs a small bootstrap inside the isolate that rebuilds the friendly API surface user code expects:
// Reconstructed inside the isolate, calls flow back through References.
const console = {
log: (...a) => __console_log.applyIgnored(undefined, [a.map(__cleanForLog)]),
warn: (...a) => __console_warn.applyIgnored(undefined, [a.map(__cleanForLog)]),
error: (...a) => __console_error.applyIgnored(undefined, [a.map(__cleanForLog)]),
};
const utils = {
slugify: (s) => __util_slugify.applySync(undefined, [s]),
iso: (d) => __util_iso.applySync(undefined, [d]),
now: () => __util_now.applySync(undefined, []),
http: {
get: async (u, o) => __util_http_request.applySyncPromise(undefined, ['GET', u, undefined, o]),
post: async (u, b, o) => __util_http_request.applySyncPromise(undefined, ['POST', u, b, o]),
request: async (m, u, o, b) => __util_http_request.applySyncPromise(undefined, [m, u, b, o]),
},
};
const graph = {
query: async (cypher, params) => __graph_query.applySyncPromise(undefined, [cypher, params]),
};
const input = __input;
The bootstrap is what makes user code look normal. Without it, user code would call __console_log.applyIgnored(...) directly — possible but ugly. The bootstrap is also where __cleanForLog lives, which strips functions and symbols from console arguments so the ExternalCopy boundary doesn't reject them.
Resource limits
- Memory cap: 32 MB default (
opts.memoryMb). Hint to V8 — actual peak can be slightly higher. The isolate hard-aborts on excess. - Wall-clock timeout: 5 seconds default (
opts.timeoutMs). The isolate is terminated on overrun. - Outbound HTTP per-request: 10s timeout, 1MB response cap, enforced host-side in
httpRequest(). - No
setTimeout/setIntervalreaching outside the isolate. The isolate has no event loop access;awaiton async References suspends the isolate while the host runs the actual work.
Execution mode: build-transform
Build-transforms run at compile time. The build pipeline iterates content records that have been edited since the last build; for each record, it checks which scripts have RUNS_AS->em:build-transform edges and applies them in order. The transform's input is the content record; the return value replaces the record in the build output.
// transform that derives word count + read time
const post = input;
const words = post.body.split(/\s+/).filter(Boolean).length;
return {
...post,
derivedReadMinutes: Math.max(1, Math.ceil(words / 230)),
derivedWordCount: words,
};
Per-record, per-build. Results are not cached across builds — every build runs the transform fresh against current code. This is intentional: it means rolling back a script version takes effect on the very next build, no cache flush.
Execution mode: inbound-webhook
Inbound webhooks run on HMAC-authenticated external POSTs at POST /api/hooks/:siteId/:slug. The slug routes to a configured InboundWebhook node which carries the shared secret. The receiver flow:
- Resolve the site → graph by
:siteId. - Look up the webhook by
:slugwithin that graph. - Verify HMAC: compute
HMAC-SHA256(secret, body), compare toX-Webhook-Signatureheader. Constant-time compare. - If signature valid: record the event as an immutable graph fact, invoke any scripts wired to this slug, return
202 AcceptedwitheventId. - If signature invalid: still record the event (with
signatureValid: false), return401witheventId. Replay viaPOST /api/inbound/events/:eventId/replayafter the secret is rotated.
The script's input for this mode is { body, headers, slug }. Return value is ignored for fire-and-forget; for synchronous response, future modes will support an explicit return shape.
HMAC signature shape
// External system computes:
signature = hex(hmac_sha256(secret, request_body))
// Sends:
POST https://app.staticowl.com/api/hooks/site:acme/stripe
X-Webhook-Signature: a3f9c2...
Content-Type: application/json
{"id": "evt_1", "type": "customer.created", ...}
Why this shape and not Stripe-style timestamped signatures: third parties already implement it. Specifically, GitHub's X-Hub-Signature-256, Slack's X-Slack-Signature, Stripe's Stripe-Signature all compute over the raw request body. We accept the simple form by default and add per-provider verifiers for the well-known integrations.
Observability
Every script invocation produces a RunResult with:
ok— boolean. False on error or timeout.result— the return value (cloneSafe-d).error—{ message, stack }if execution threw.logs—[{ level, args }]— every console call captured in order.durationMs— wall-clock from spawn to return.
RunResults for build-transforms are attached to the build's audit record; for inbound-webhook invocations, attached to the InboundEvent graph node. Both queryable via the admin UI, the /api/scripts/:name/runs endpoint, and the standard db.changes() bitemporal queries.
Failure modes
| Failure | Detection | Consequence |
|---|---|---|
| Out of memory | isolate aborts; RunResult.error.message = "Isolate was disposed" |
For build-transform: record left untransformed; build records the error. For webhook: 500 returned to caller, event recorded. |
| Timeout | isolate terminated; error.message = "Execution timed out" |
Same as OOM. Wall-clock is the only timer. |
| User threw inside script | RunResult.error captures the throw with stack | Same as above; error is the script's, not ours. |
| Outbound HTTP timeout | 10s per-request timeout returns to the script as a rejected promise | Script can catch and decide. The default behavior is to fail the run. |
| Outbound HTTP response > 1MB | Read truncated; r.truncated: true in response |
Script can detect and handle. No automatic retry. |
| Capability not granted | The relevant Reference isn't installed; calling it throws TypeError | Script fails at the call site with a clear message. Add the capability at the next deploy. |
| Graph query attempt to write | graph.query only accepts read-only Cypher; engine rejects MERGE/CREATE/DELETE |
Query fails with a 4xx; not a security issue, just a contract. |
What's not yet shipped
em:on-request— per-request HTTP handlers at/api/run/:siteId/:slug. Scoped to public-readable resources; rate-limited similar to/api/public/search. Q3 2026.em:scheduled— cron-style triggers. Q3 2026.- Edge runtime — running the same isolate at the CDN. Q4 2026 if demand materializes.
- Pyodide runtime — Python support via WebAssembly. Investigating.
- Multi-version traffic splitting — pin a percentage of invocations to a new version for canary deploys. On the roadmap; ScriptVersion nodes already support it at the data model layer.
If any of these block your use case, talk to us before you commit — we'll prioritize what design partners need.
Related reading
- Functions overview (marketing-side feature page)
- Audit chain — every script deploy is a graph fact in the chain; rotate / rollback / revoke are queryable.
- Architecture overview — how the compile + audit + content layers fit together.