The short version
You write JavaScript. We run it in a hardened V8 isolate inside the platform. It can read the content graph, make outbound HTTP, use a curated set of host helpers — and absolutely nothing else. No require, no process, no fs, no ambient globals. When it's done, the result is captured: logs go to your dashboard, return values flow into the calling context, errors stamp the audit chain.
There are two execution modes today:
- build-transform — runs at compile time on content records. Mutate fields, derive new content, enrich with external data, validate against your custom rules.
- inbound-webhook — runs on HMAC-authenticated external events at
/api/hooks/:siteId/:slug. Stripe, GitHub, your custom services — your code reacts.
Why isolated-vm and not node:vm
This part matters more than it sounds. Node's built-in vm module shares the host heap. Well-known prototype-pollution escapes let JS reach process, require, fs, the network — the Node docs explicitly warn it is not a security boundary.
isolated-vm runs user code in a separate V8 isolate. Clean heap. No shared globals. Prototype tricks can't reach anything outside the isolate, because there is no anything outside the isolate to reach. The host exposes specific capabilities via ivm.Reference — those functions are callable from inside but execute on the host with our checks. Everything else is genuinely unavailable.
Cost: ~5-10ms per spawn vs ~0.1ms for node:vm. Acceptable for hooks that fire on save / publish / before-render. Not for per-request hot paths — for which our architecture is "compile once, serve static." Different shape.
What's exposed inside the isolate
// console — captured to your dashboard logs
console.log('hello'); console.warn('warn'); console.error('err');
// input — value passed by the calling context (transform input,
// webhook body, etc.) cloned into the isolate via ExternalCopy
const i = input;
// utils — pure host-side helpers, no I/O
utils.slugify('Hello World'); // 'hello-world'
utils.iso(); // '2026-06-06T14:32:17.123Z'
utils.now(); // same; convenience alias
// utils.http — outbound HTTP, 10s timeout per request, 1MB response cap
const r = await utils.http.get('https://api.example.com/x');
const j = await utils.http.post('https://api.example.com/y', { body: 'hi' });
await utils.http.request('PATCH', url, { headers: {...} }, bodyBytes);
// graph.query — READ-ONLY graph query. Cypher, parameterized.
// Returns rows; write operations are intentionally not exposed.
const rows = await graph.query(
'MATCH (n:BlogPost) WHERE n.slug = $slug RETURN n LIMIT 1',
{ slug: 'hello-world' },
);
Everything else does not exist in the isolate. There is no require, no process, no fs, no __dirname, no setTimeout reaching outside, no module system. Your code is a small island with a few well-marked piers.
Memory + timeout
- Memory cap: 32MB by default. Hint to V8; actual peak can be slightly higher. The isolate hard-aborts on excess. Configurable per script.
- Wall-clock timeout: 5 seconds default. The isolate is terminated on overrun; your function returns an error.
- Outbound HTTP: per-request 10s timeout, 1MB response cap, host-side enforced.
Runaway code can't OOM the host, can't tie up the event loop, can't fork processes, can't open arbitrary network connections beyond the policy.
Capabilities + secrets
Scripts declare what they're allowed to reach when you deploy them. The capability model is inversion-of-control: instead of "everything by default, restrict in code," you opt in to specific capabilities:
POST /api/scripts
{
"name": "stripe-paid-event",
"code": "...",
"runtime": "nodejs22",
"capabilities": ["http.outbound", "graph.read"],
"secrets": ["STRIPE_WEBHOOK_SECRET"]
}
The script is granted those capabilities (and only those) at the engine layer. Named secrets are accessible via secrets.STRIPE_WEBHOOK_SECRET inside the isolate without ever appearing in the code. The Secret nodes live in the graph; rotation is one mutation.
Two execution modes, two use cases
build-transform — runs at compile time
The transform receives the content record being compiled, mutates / enriches / validates it, and returns the modified shape. Runs once per affected record per build; results captured in the build's audit trail.
// transform that enriches blog posts with read-time + word count
const post = input;
const wordCount = post.body.split(/\s+/).length;
const readMinutes = Math.max(1, Math.ceil(wordCount / 230));
return {
...post,
derivedReadMinutes: readMinutes,
derivedWordCount: wordCount,
};
Typical uses: enrich content from external APIs (weather for travel posts, stock prices for finance content), validate against custom business rules, derive computed fields the editor shouldn't have to type, normalize legacy data shapes on the way out.
inbound-webhook — runs on external events
The handler receives the HMAC-authenticated webhook body + headers. Returns either an HTTP response (synchronous) or fire-and-forget for background work.
// handler for Stripe customer.subscription.created
const event = JSON.parse(input.body);
if (event.type !== 'customer.subscription.created') return;
const customerId = event.data.object.customer;
const rows = await graph.query(
'MATCH (u:User { stripeCustomerId: $cid }) RETURN u',
{ cid: customerId },
);
// ... do something with the user — call out to your CRM, send an
// email via utils.http.post, log a graph fact via outbound HTTP
// to your own write endpoint, etc.
Typical uses: react to external events from Stripe / GitHub / Slack / your custom services, trigger emails or notifications, sync external state into your CMS, gate access flows.
How this maps to "serverless functions" elsewhere
If you're coming from Cloudflare Workers, Vercel Functions, AWS Lambda, or Netlify Functions, here's the honest comparison:
| Capability | StaticOwl Functions | Workers / Vercel |
|---|---|---|
| Sandboxed execution | ✓ isolated-vm | ✓ V8 isolates |
| Per-request HTTP handler | — inbound-webhook only (HMAC-auth required) | ✓ any request |
| Compile-time execution | ✓ build-transform mode | — (deploy hooks at most) |
| Content-graph reads from inside | ✓ graph.query (read-only) |
— bring your own DB client |
| Capability model | ✓ declarative, scoped | — bindings, runtime checks |
| Versioning + rollback | ✓ ScriptVersion as graph fact | ✓ deploy history |
| Global edge runtime | — runs in our region | ✓ 100+ POPs |
| Cold start | ~5–10ms per spawn | <1ms (warmed) |
Honest framing: this is not a general-purpose serverless functions market replacement. Functions are designed around the content compile + audit lifecycle. If you need per-request HTTP at every edge POP, keep your Workers. If you need code that runs on content compile, on inbound webhooks, with first-class graph reads and a tight security envelope — this is what you'd build yourself, and it's already built.
What's coming
- on-request execution mode — per-request handlers at
/api/run/:siteId/:slug, scoped to public-readable resources. Q3 2026. - Scheduled execution — cron-style triggers. Q3 2026.
- Edge runtime — running the same isolate at the CDN. Q4 2026 if customer demand materializes.
- More runtimes — Python via Pyodide is on the roadmap; Wasm runtime is being evaluated.
If any of those land before you commit, talk to us — design partners get priority access and pin scope.