Reference

Block Scripting

Everything you can do inside a Process block. Code runs server-side in a sandboxed JavaScript engine (Jint) — with HTTP, databases, crypto and full control over the HTTP response, but no Node.js, no filesystem, and tight resource limits.

The execution model

A flow is a grid of blocks connected by links. Data flows along links; each block transforms its input and returns an output that travels downstream.

Block typeWhat it does
ProcessRuns your JavaScript (in code). This page is about these.
Raw dataEmits a static JSON/text payload (in rawData).
API Endpoint
shown as Endpoint in the editor
Exposes the flow over HTTP at a path; execution starts here for gateway calls. Serves an API reply or a published page.

Two rules to internalise:

Globals at a glance

input

Upstream block's output

import

Reuse code files across blocks, or pull in an npm package

request

The HTTP request (endpoint flows)

fetch / XMLHttpRequest

Outbound HTTP (sync)

sql.query

Databases via the vault

db.query

This workspace's own SQL database

store

Built-in key–value storage

crypto / base64

Hashing, HMAC, encoding

Response

Status, headers, content-type

channel

Real-time push (SSE)

runner

Pairing and targeting on-prem machines

console / JSON

Logging & parsing

standard globals

TextEncoder, timers, AbortController…

limits

Time, memory, what's blocked

Input & output

The variable input holds the output of the block immediately upstream. Return a value to pass it on.

// A block linked downstream of another
const total = input.items.reduce((a, x) => a + x.price, 0);
return { ...input, total };

The first block in a chain has no upstream, so input is undefined there. For endpoint flows, read the request from request (below) instead.

Sharing code — import

When logic outgrows a single block, put it in a code file and import it. Manage files from the Code files button in the canvas toolbar (a tree + editor); each file is plain JavaScript that defines things (functions, constants). A block pulls them in by starting with import lines:

import "utils.js";
import "templates/html.js";   // folders with "/"

return renderPage(sanitize(request.body));   // functions from the files

utils.js is just:

function sanitize(s) { return String(s).trim(); }
const VERSION = 2;

Files define, blocks return. An imported file is evaluated into the same sandbox before your block runs — like a <script> tag — so everything it declares is in scope. A file may import other files (loaded dependencies-first; cycles are rejected) but must not have a top-level return and doesn't receive input. Files travel with the workflow: they're versioned with your commits and available to deployed endpoints. Same sandbox limits apply.

npm packages

The same import line pulls in a curated npm package, pinned to an exact version:

import "zod@4.4.3";
import "date-fns@4.4.0";

const Order = zod.z.object({ id: zod.z.string(), qty: zod.z.number() });
const order = Order.parse(request.body);        // throws on bad input
return { ...order, at: dateFns.format(new Date(), "yyyy-MM-dd") };

Each package arrives as one global — the whole module namespace under a name safe to write in JavaScript, so date-fns becomes dateFns:

ImportGlobalWhat it's for
"zod@4.4.3"zodschema validation of request bodies
"date-fns@4.4.0"dateFnsdate parsing, formatting, arithmetic
"papaparse@5.5.4"papaparseCSV in and out
"fast-xml-parser@5.10.1"fastXmlParserXML in and out
"nanoid@6.0.0"nanoidshort URL-safe ids — nanoid.nanoid() (21 chars). For a standard UUID use the built-in crypto.randomUUID() instead

The version is required, and that's the point. A deployed endpoint runs the workspace version you committed. Because the pin lives in your block's source, it's frozen in that commit too — so an endpoint you deployed months ago keeps running the exact package build it was tested against, no matter what we stock later. An unpinned import "zod" is rejected, and asking for a version we don't carry fails loudly rather than quietly giving you a different one.

Packages are bundled by us and served from the sandbox — nothing is fetched at runtime, so there's no install step and no network call. A code file of your own always wins over a package of the same name. Everything else on this page still applies: a package runs under the same memory, time and statement limits as your own code, and can't reach Node APIs.

Files that aren't JavaScript

A code file may also be .css, .html, .json, .svg, .txt, .md, .xml or .csv. Only .js can be imported (an import evaluates the file); read anything else as raw text:

CallReturns
files.read(name)the file's text · throws if it doesn't exist
files.has(name)bool
files.list()every file name
const html = files.read("pages/index.html").replace("{{name}}", athlete.name);
return Response(html, 200, { "Content-Type": "text/html; charset=utf-8" });

Don't paste a page into a block as a template literal. Inside backticks the JS parser eats \d in regexes and interprets ${…}, so markup breaks in ways that are painful to spot. Store the page as a real .html/.css file instead.

Serving a file directly — static endpoints

For CSS, client-side JS, or a page with no templating, you don't need a Process block at all. Give the Endpoint block a static file and the gateway serves it verbatim, with the content type inferred from the extension and no JavaScript executed:

// API Endpoint block: endpointPath "app.css", staticFile "pages/app.css"
// → GET https://api.qu4zr.io/<domain>/app.css  serves the file as text/css

It's cheaper than executing a block per request, and your page can then reference <link rel="stylesheet" href="app.css"> normally.

Your site's address

A domain answers at two addresses, and both keep working:

AddressWhat it's for
https://<domain>.qu4zr.com/ Your site. This is the one to give people, and the one your <link rel="canonical"> should point at. .fr serves the same pages.
https://api.qu4zr.io/<domain>/ The same content under the API gateway — what existing API callers use. It is not redirected, so nothing you built against it breaks.

Because both serve, the canonical tag is what decides which one search engines index — so put one on every page and always base it on the address the platform reports back to you when you deploy (also the baseUrl in the domains list), never a URL you assemble by hand.

What an endpoint path may be

A path is one or more segments of letters, digits, -, _ and ., separated by /. Two of those matter for a site rather than an API:

PathURLUse
orders/<domain>/ordersan ordinary API endpoint
users/{userId}/<domain>/users/42a path parameter, read as request.params.userId
index.html/<domain>/index.htmla file name — how you publish sitemap.xml, robots.txt or a search-engine verification file
//<domain>/the root — your site's home page

The root is the URL people link to and the one your <link rel="canonical"> should point at. Both /<domain> and /<domain>/ serve it; pick one in your canonical tag so search engines index a single address. . and .. are refused as segments — an HTTP client strips them out of a URL before the request is sent, so an endpoint deployed under one could never be reached.

What search engines see

Three things are handled for you, so a published page is findable and a deployed API is not:

WhatHow it works
sitemap.xml Generated from the pages you have actually deployed — every enabled, public endpoint serving an .html file. Nothing to maintain, and it cannot list a page you removed. Deploy your own endpoint at that path to override it, which you need if your pages are rendered by a Process block rather than served as files.
robots.txt On <domain>.qu4zr.com it is yours: generated to allow crawling and to point at your sitemap, so a search engine finds every page without you submitting anything. Deploy your own endpoint at robots.txt to replace it. On the API gateway host the file belongs to the platform (one host, everyone's endpoints) — it allows crawling and turns away the commercial SEO bots, whose requests would count against your plan.
X-Robots-Tag Sent as noindex on every reply that isn't text/html — your JSON endpoints, stylesheets and scripts stay out of search results. Pages are left alone, so your own <meta name="robots"> decides. To keep one page out of search, put <meta name="robots" content="noindex"> in it.

What's left is the page itself — a title, a description, a canonical URL. Open an .html file in the Code Files panel and the Search engines drawer under the editor shows the search result it would produce and what it is still missing, as you type. Worth knowing: a crawler's request is an API call like any other, so it counts against your plan's quota.

The HTTP request

When a flow runs because someone called its Endpoint, the request is available as the request global:

PropertyTypeExample
request.methodstring"POST"
request.headersobjectrequest.headers["x-signature"]
request.queryobjectrequest.query.page
request.bodystringthe raw request body
request.paramsobjectpath params, e.g. request.params.userId for /users/{userId}
const body = JSON.parse(request.body || "{}");
const page = Number(request.query.page || 1);
return { method: request.method, page, name: body.name };

Outbound HTTP — fetch

fetch(url, options?) is synchronous and returns a response object directly — no await.

const res = fetch("https://api.example.com/items", {
  method: "POST",
  headers: { "Authorization": "Bearer " + input.token },
  body: { name: "widget" }            // an object is sent as JSON
});
if (!res.ok) throw new Error("HTTP " + res.status);
return res.json();              // or res.text()

The response object: { ok, status, statusText, body, text(), json() }. XMLHttpRequest is also available (synchronous polyfill).

Sending something other than JSON

An object body is serialised to JSON and sent as application/json. A string body is sent verbatim, and your Content-Type is honoured — which is what OAuth2 token endpoints, XML SOAP services and CSV uploads need.

// OAuth2 token exchange — form-encoded, not JSON
const res = fetch("https://oauth2.googleapis.com/token", {
  method: "POST",
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
  body: "grant_type=authorization_code&code=" + encodeURIComponent(code)
});

The header name is case-insensitive, and parameters survive (e.g. a multipart/form-data boundary). Omit Content-Type and the previous default — application/json; charset=utf-8 — still applies. Content-Length is always computed for you and ignored if you set it. A header that can't be applied now raises an error rather than being dropped silently.

Limits & safety: HTTP/HTTPS only · localhost & private IPs are blocked (SSRF guard) · max 10 requests per execution · 10s timeout · 5 MB max response. To reach a resource on a private network, push to it via a SignalR relay rather than calling its IP directly.

Databases — sql.query & the vault

Query Postgres, MySQL, SQL Server, Oracle, or SQLite with parameterised SQL. The connection is a vault secret you add once in the UI (Workspace → Vault); your code references it by an opaque handle — the connection string never appears in code or logs.

// MyVault.prodDb is a Connection secret added in the Vault UI
const rows = sql.query(
  MyVault.prodDb,                                  // <Vault>.<secret> handle
  "SELECT id, email FROM users WHERE id = @id",   // @name or :name placeholders
  { id: request.params.userId }                    // parameters — never string-concat
);
return rows;   // → [ { id: 1, email: "a@b.com" }, ... ]

Always parameterise (@id, never "... " + userId) — it's safe against SQL injection and the only supported form. Limits: max 20 queries per execution, 10k rows per query.

Using a vault secret in an API call

A handle also works as a header value in fetch — the secret is substituted server-side, at the moment the request goes out, and is replaced with *** in your execution logs. Your block never sees the value:

const res = fetch("https://api.stripe.com/v1/charges", {
  headers: { "Authorization": MyVault.stripeKey }   // handle, not a string
});

For an API that wants the key in the URL, use a {{Vault.secret}} placeholder — string concatenation can't work, because a handle has no readable value to concatenate:

const res = fetch("https://api.example.com/v1/items?key={{MyVault.apiKey}}");

Placeholders are substituted in the path and query only, never the host — a secret must not be able to change where the request goes. The URL is logged with the placeholder intact. An unknown vault or secret name raises an error rather than sending the literal {{…}} text.

Your own database — db.query

Every workspace can have its own small SQL database — a private SQLite file, created the first time something writes to it. There is no connection string, no vault secret and nothing to provision: db is already pointed at the database that belongs to this workspace, and nothing outside the workspace can reach it.

Reach for it when you want rows — things to filter, sort, join, aggregate or count. Reach for store when you want a single value looked up by key. They are different tools and both are built in.

// Idempotent schema, at the top of the block. The block runs before anyone
// has set anything up — and a FORK of this workspace starts empty.
db.query("CREATE TABLE IF NOT EXISTS tasks (id INTEGER PRIMARY KEY, title TEXT NOT NULL)");

// Always parameterised — @name or :name, never string concatenation
db.query("INSERT INTO tasks (title) VALUES (@title)", { title: request.body });

const rows = db.query("SELECT * FROM tasks ORDER BY id DESC LIMIT 20");
return { count: rows.length, tasks: rows };

DDL is allowed, so a block can create what it needs. Write it idempotently: your code is what gets copied when somebody forks your workspace from the gallery — your rows are not, and a block that assumes a table exists opens onto an empty screen.

Looking at it without writing code

The canvas has a Data view beside Grid and Logs: the tables, their columns, and a SQL editor that runs against the same database your blocks use. The AI assistant can read and write it too.

Not every plan has one

db is undefined when the workspace has no database, rather than present and failing on every call — so a block meant to run on any plan can branch:

if (typeof db === "undefined") {
  // no database on this plan — fall back to the key-value store
  return store.list("task:").map(e => e.value);
}

Not versioned with your flow, exactly like the store: restoring an older version of the grid does not roll the rows back. Scope is the workspace (shared by collaborators, never visible to another workspace). It is SQLite, so the SQLite dialect applies — INTEGER PRIMARY KEY is already auto-incrementing, and there is no native date type, so store ISO text. Limits: 20 queries per execution, 10k rows per query, 10s per statement, plus a per-plan size ceiling — a write that would cross it fails with "database or disk is full" rather than being silently truncated.

Persistent storage — store

A built-in per-workspace key–value store for state that must outlive a single run — counters, caches, form submissions, dedup keys. No database or vault to set up; values are any JSON.

CallDoes
store.set(key, value, { ttl? })Create/replace. ttl = seconds until it expires (optional)
store.get(key)The value, or null if missing/expired
store.has(key) · store.delete(key)bool
store.increment(key, delta?)Atomic counter (delta defaults to 1) → new number
store.cas(key, expected, next, { ttl? })Atomic compare-and-swap → bool. Sets next only if the current value equals expected (a missing key counts as null, so expected: null initializes)
store.list(prefix, { limit?, after? })[{ key, value, updatedAt }], key-ordered · after = paging cursor
store.count(prefix)Number of keys with that prefix
store.getMany(keys){ key: value } for up to 100 keys — one operation, not one per key. Missing keys are simply absent
store.setMany({ k: v, … }, { ttl? })Write up to 100 entries in one operation and one statement → number written. A repeated key is rejected
store.deletePrefix(prefix)Delete every key under a prefix in one operation → number deleted. An empty prefix is refused

A persistent counter

// Safe under concurrent endpoint calls — increment is atomic
const views = store.increment("views");
return { views };

Store & list submissions (a no-backend form API)

if (request.method === "POST") {
  store.set("sub:" + crypto.randomUUID(), JSON.parse(request.body));
  return Response({ ok: true }, 201);
}
return store.list("sub:").map(e => e.value);   // GET → all submissions

Cache an upstream response

let data = store.get("weather");
if (!data) {
  data = fetch("https://api.example.com/weather").json();
  store.set("weather", data, { ttl: 300 });   // expire after 5 min
}
return data;

Not versioned with your flow. The store is live runtime data (like a database), not part of the workflow you commit — restoring an older version of the grid does not roll back stored data. Scope is per workspace (shared by collaborators). Limits: key ≤ 256 chars, 50 ops per execution, 100 keys per batch, plus a per-plan entry/size quota. The per-value cap scales with your plan rather than being one flat number; if a value is refused, the error states the actual limit.

Batch instead of looping. Twenty store.get calls burn twenty of the fifty operations an execution gets; store.getMany(keys) costs one. The same goes for writing — and for cleanup: reclaiming a chunked dataset is store.deletePrefix("chunk:"), never a delete-per-key loop, which would run out of budget and leave orphans behind consuming quota.

Crypto & encoding

Hashing, HMAC (with a constant-time verify), random ids, and base64 — enough to verify webhooks and sign requests.

FunctionReturns
crypto.sha256(data, enc?)hash · enc = 'hex' (default) / 'base64' / 'base64url'
crypto.hmacSha256(key, data, enc?)HMAC-SHA256 · key may be a vault handle
crypto.hmacSha256Verify(key, data, expected, enc?)bool · constant-time compare
crypto.md5(data, enc?)hash (legacy APIs)
crypto.randomUUID() · crypto.randomBytes(n, enc?)random id / bytes
crypto.pbkdf2(password, salt, iterations?, keyBytes?, enc?)password hash · PBKDF2-HMAC-SHA256
crypto.pbkdf2Verify(password, salt, iterations, expected, keyBytes?, enc?)bool · constant-time compare
crypto.verifyJwt(token, keySource, options?)the token's claims, or throws
crypto.verify(alg, data, signature, publicKeyPem)bool · RS256/384/512, ES256/384/512
base64.encode(s) · base64.decode(s) · btoa(s) · atob(s)UTF-8 base64

Hashing passwords

Never store a password with sha256. Use crypto.pbkdf2: its iterations run in host code, so they cost nothing from your block's statement budget — real key stretching is affordable here, unlike a hand-rolled HMAC loop.

// Register: one random salt per user, stored alongside the hash
const salt = crypto.randomBytes(16);
const hash = crypto.pbkdf2(password, salt, 210000, 32);
store.set("user:" + email, { salt, hash, iterations: 210000 });

// Log in — constant-time compare
const u = store.get("user:" + email);
if (!u || !crypto.pbkdf2Verify(password, u.salt, u.iterations, u.hash)) {
  return Response({ error: "invalid credentials" }, 401);
}

Defaults: 100k iterations, 32-byte key, hex. Limits: 600k iterations and 5 calls per execution (each call is real CPU). Store the iteration count with the hash so you can raise it later without locking anyone out. The password argument accepts a vault handle if you want to pepper it with a server-side secret.

Verifying a JWT (Google / Auth0 / any OIDC issuer)

keySource is either a JWKS URL (fetched and cached, and it does not consume your 10-request fetch budget) or an inline PEM public key. Signature, exp and nbf are always checked; iss and aud are checked when you name them — and you should. Invalid tokens throw, so a failed check can't be mistaken for a pass.

const claims = crypto.verifyJwt(idToken, "https://www.googleapis.com/oauth2/v3/certs", {
  issuer: "https://accounts.google.com",
  audience: "<your-client-id>.apps.googleusercontent.com"
});
return { userId: claims.sub, email: claims.email };

Only public keys are involved — the sandbox cannot sign, only verify. A JWKS URL goes through the same SSRF guard as fetch, so it must be a public host.

Verify a webhook signature

The signing key can be a vault handle, so the secret is resolved server-side and never sits in your block:

const sig = (request.headers["x-hub-signature-256"] || "").replace(/^sha256=/, "");
if (!crypto.hmacSha256Verify(MyVault.webhookSecret, request.body, sig)) {
  return Response({ error: "invalid signature" }, 401);
}
return { ok: true };

Controlling the HTTP response — Response()

By default an endpoint returns your value as JSON with status 200. To set the status, headers, or content-type, return Response(body, status?, headers?).

// Custom status + JSON
return Response({ created: true }, 201);

// XML — a string body + Content-Type is sent VERBATIM (not JSON-wrapped)
return Response("<ok/>", 200, { "Content-Type": "application/xml" });

// CSV
return Response("a,b\n1,2", 200, { "Content-Type": "text/csv" });

// Redirect
return Response("", 302, { "Location": "https://example.com" });

Framing headers (Content-Length, Transfer-Encoding, …) are managed by the server and can't be overridden. A string body without a Content-Type is still returned as JSON — set the content-type to send raw text.

Choosing an on-prem machine — runner.target

A SignalR block runs its C# handler on a real machine. Which one is not a guess: with no target it routes to your Developer Runner — this dashboard's own machine — and nowhere else. That is what you want while you build.

An application that ships a runner to its own users has many of them on one block, one per user, so the flow has to say which. runner.target(deviceId) sets it for that block and everything downstream of it:

// Endpoint block: authenticate the caller, then pick THEIR machine.
const deviceId = store.get("remote:" + input.query.t);
if (!deviceId) return Response({ error: "unknown remote" }, 404);

runner.target(deviceId);   // the linked SignalR block now runs on that machine
return { command: input.body.command };
CallDoes
runner.target(deviceId)Routes downstream SignalR blocks to that consumer runner. null returns to the Developer Runner.
runner.currentTarget()The device id in force, or null.
runner.claimPairing(pairingId, name?)Binds a machine that just installed your runner to your application. Returns { deviceId, blockId, name }, or null if the pairing expired or was already claimed.

Never pass a device id straight from the request. runner.target(input.body.deviceId) hands the choice of whose computer to drive to whoever called your endpoint. Resolve it from your own record of the signed-in user — the store is the obvious place — and target that.

Branches are independent: a flow that fans out can drive two users' machines at once, and one branch retargeting never moves another.

Pairing a new machine — runner.claimPairing

A customer downloads your runner, launches it, and it opens your pairing page carrying a pairingId. Claiming that pairing is what turns their machine into a device id you can target. It needs no credential of any kind: the block already runs as the workspace owner, and the owner is exactly who may claim a pairing on their own block.

// Endpoint block, called by your own page with the pairingId from the URL.
const device = runner.claimPairing(input.body.pairingId, "Living room PC");
if (!device) return Response({ error: "pairing_expired" }, 410);

// Remember whose machine it is — this is what runner.target reads later.
store.set("remote:" + mySessionToken, device.deviceId);
return { paired: true };

Do not build an API key into a consumer application. The Management API can claim a pairing too, but that route exists for integration tests: it needs OAuth client credentials belonging to the account, sitting in a workspace, reachable by every block in it — to do something the platform can already authorise from the execution itself. Nobody, least of all the person who downloaded your app, should ever be asked for a key.

null covers expired, already claimed and never existed, on purpose: the id arrives from end-user traffic, so telling those apart would make this a way to discover pairings. Show “this code has expired — restart the agent”. At most five claims run in one execution.

A machine can pair more than once. A user adding a second phone, or recovering one they lost, reopens your pairing page from the runner's tray icon, and claimPairing then returns the same deviceId it returned the first time — one computer is never two devices. So treat your token→device mapping as an upsert rather than assuming every claim is a new machine.

Real-time push — channel & Response.stream

Push updates to browsers instantly instead of making them poll. A block publishes to a named channel; a public page subscribes to a live stream of that channel over Server-Sent Events. Channels are per-workspace — and, unlike everything else here, not per-version.

CallDoes
channel.publish(name, data)Fan data (any JSON) out to every subscriber of the channel → returns how many received it
Response.stream(name, initial?)Return from an Endpoint block to serve an SSE stream of the channel; initial is an optional first event (e.g. the current state)

Publish when state changes

// e.g. in the block that handles an admin command:
const state = { status: "running", at: store.get("startedAt") };
store.set("state", state);
channel.publish("timer", state);   // every open display updates instantly
return Response({ ok: true });

Stream to browsers (an Endpoint block)

// The public display endpoint — its whole job is to open the stream,
// seeded with the current state so a page that just connected isn't blank:
return Response.stream("timer", store.get("state"));

On the page

const es = new EventSource("https://api.qu4zr.io/my-api/timer");
es.onmessage = (e) => {
  const state = JSON.parse(e.data);
  render(state);                // reconnects automatically if the connection drops
};

One-way (server→browser) and JSON per message. A stream counts as a single API request (charged once on connect), so it's far cheaper than polling. Subscribers that fall behind drop their oldest queued messages rather than blocking anyone. Limits: names ≤ 128 chars, messages ≤ 64 KB, 50 publishes per execution, plus a per-workspace concurrent-stream cap. Delivery is best-effort within one server instance — publish full state (not deltas) so a reconnecting client is always correct.

Streams and workspace versions

A deployed endpoint runs the workspace version you committed, frozen — its blocks, its code files, its package pins. Channels are the one exception. They are keyed by workspace only, so a publish reaches every subscriber of that channel name whatever version published it and whatever version they subscribed through:

// A browser connected to  /my-api/v1/timer
// RECEIVES events published by a block running under  v2  — and vice versa.

That's usually what you want: one live topic per workspace, not a separate realtime universe per deployed version. It has three consequences worth planning for.

When you…What happensDo this
Deploy a version that changes the shape of a published message Old clients still connected start receiving the new shape. Nothing errors — they silently read fields that moved or vanished Version the channel name ("timer-v2") whenever the payload changes. Two topics, no silent break
Roll out while old clients are still connected Both versions share one per-workspace concurrent-stream cap, so a migration can hit it when neither version would alone (503) Expect roughly double the streams mid-rollout; keep pages reconnecting rather than retrying in a tight loop
Deploy at all Every open stream on the workspace drops at once and every client reconnects together. Each reconnect re-runs the endpoint block and counts as a new API request Deploy when few streams are open if you can; remember streams are billed per connect, so deploy frequency × concurrent viewers is a real cost

There is no replay. Events carry no id, so Last-Event-ID can't resume a stream — anything published while a client was reconnecting is gone, and a subscriber that falls far behind drops its oldest queued messages. This is why the advice above is to publish full state rather than deltas: a client that just reconnected is then correct immediately, with no gap to recover.

Standard globals

Beyond the qu4zr APIs above, the sandbox provides the standard globals most JavaScript expects — usable directly in your blocks, and what lets the npm packages load:

GlobalNotes
crypto.getRandomValues(arr)Fills a typed array from a cryptographically secure source. Max 65536 bytes per call. (The qu4zr crypto helpers are on the same object.)
TextEncoder / TextDecoderUTF-8 only — correct for emoji and non-Latin scripts, not just ASCII
setTimeout / setImmediateRuns the callback immediately. A block is one synchronous pass with no event loop to return to, so a deferred callback would otherwise never run at all
setIntervalAccepted but never fires — repeating forever would hang the execution, and firing once would misrepresent it
queueMicrotaskRuns immediately, same reasoning
Event, EventTarget, AbortControllerMinimal but functional — listeners fire on abort
performance.now()Milliseconds, backed by the clock
self, globalBoth point at the global object

window stays undefined on purpose. Libraries test for it to decide whether they are in a browser; defining it would send them down the DOM path and they would fail reaching for document. There is no Buffer and no process — see Limits.

Logging & JSON

Limits & what's blocked

ResourceLimit
CPU time per block~5 seconds
Memory16 MB
Statements50,000 · read what's left with runtime.statementsRemaining()
Recursion depth50
HTTP requests / execution10 (10s, 5 MB each)
SQL queries / execution20 (10k rows each)
Store ops / execution50 (key ≤ 256 chars, value ≤ 64 KB)
PBKDF2 / execution5 calls (≤ 600k iterations each)

A long aggregation doesn't have to gamble on the statement cap — check the budget and return partial results with a cursor instead of being cut off:

for (const row of rows) {
  if (runtime.statementsRemaining() < 500) return { done: false, nextCursor: row.id, total };
  total += row.amount;
}
return { done: true, total };

Not available (by design, for security):

Write portable JavaScript. Need base64 or HMAC? Use the built-ins above rather than reaching for a library — and when you do need one, check the curated packages before hand-rolling it.

On-prem (SignalR) blocks

Some logic must run on your machine or network — read the local time, query an internal database, touch local files — which the cloud sandbox deliberately can't reach. A SignalR block runs on a small runner you install; it connects outbound, so there's no VPN and no inbound ports. You write its logic in C# (not the sandboxed JS above):

// runs on your runner, UNsandboxed:  object Execute(JsonElement input, IReadOnlyDictionary<string,string?> local)
return new { now = DateTime.Now.ToString("o") };

// a local database — the connection string lives in the runner's appsettings "Local",
// read as local["name"] (never in code). provider: postgres|mysql|sqlserver|oracle|sqlite
var rows = RunnerDb.Query("postgres", local["customersDb"], "SELECT id, name FROM customers LIMIT 50");
return new { count = rows.Count, customers = rows };

The handler is baked into the runner you download — qu4zr never pushes code to a running runner, and your secrets (in appsettings.json under "Local") stay on your machine. You only ever edit Handler.cs; everything under _internal/ is generated plumbing.

Let the AI set it up for you

With the qu4zr MCP server connected, the assistant authors the C# handler and can stand the runner up for you end-to-end — you just approve the sensitive steps:

StepWhat happens
Set upThe assistant downloads the runner to your machine, builds it, onboards it, and launches it. The handler runs unsandboxed (full local access) — that's the point of on-prem, so review Handler.cs before you let it run.
Test & iterateRun the runner with dotnet run -- --test (opt-in). The assistant compiles & runs the handler on your machine, reads the result, and fixes it until it's green — the on-prem twin of testing a Process block. A --test runner serves your live endpoint at the same time.
Stay always-onA plain dotnet run stops when you close the terminal or reboot. Install the runner as a background OS service (Windows service / systemd unit) so the endpoint survives reboots — this needs an admin / UAC / sudo approval, which is your gate.

Test mode is the only path that executes pushed code, it's opt-in (you launch with --test), and the runner prints the handler before running it. Production runners stay push-free. Connection strings live in the runner's "Local" config and are never sent to qu4zr or the AI.

Build it by chatting

You don't have to write any of this by hand. Connect the qu4zr MCP server to Claude, Cursor, or Kiro and describe what you want — the assistant knows every API on this page, can test a block before deploying, and wires up vault secrets and endpoints for you.