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.
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 type | What it does |
|---|---|
Process | Runs your JavaScript (in code). This page is about these. |
Raw data | Emits a static JSON/text payload (in rawData). |
API Endpointshown 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:
async/await and no Promises —
fetch() and sql.query() return their result directly. (Any await you write is stripped on the server.)returns becomes its output — passed to the next block, or sent as the HTTP response for an endpoint.Upstream block's output
Reuse code files across blocks, or pull in an npm package
The HTTP request (endpoint flows)
Outbound HTTP (sync)
Databases via the vault
This workspace's own SQL database
Built-in key–value storage
Hashing, HMAC, encoding
Status, headers, content-type
Real-time push (SSE)
Pairing and targeting on-prem machines
Logging & parsing
TextEncoder, timers, AbortController…
Time, memory, what's blocked
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.
importWhen 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.
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:
| Import | Global | What it's for |
|---|---|---|
"zod@4.4.3" | zod | schema validation of request bodies |
"date-fns@4.4.0" | dateFns | date parsing, formatting, arithmetic |
"papaparse@5.5.4" | papaparse | CSV in and out |
"fast-xml-parser@5.10.1" | fastXmlParser | XML in and out |
"nanoid@6.0.0" | nanoid | short 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.
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:
| Call | Returns |
|---|---|
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.
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.
A domain answers at two addresses, and both keep working:
| Address | What 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.
A path is one or more segments of letters, digits, -, _ and .,
separated by /. Two of those matter for a site rather than an API:
| Path | URL | Use |
|---|---|---|
orders | /<domain>/orders | an ordinary API endpoint |
users/{userId} | /<domain>/users/42 | a path parameter, read as request.params.userId |
index.html | /<domain>/index.html | a 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.
Three things are handled for you, so a published page is findable and a deployed API is not:
| What | How 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.
When a flow runs because someone called its Endpoint, the request is available as the
request global:
| Property | Type | Example |
|---|---|---|
request.method | string | "POST" |
request.headers | object | request.headers["x-signature"] |
request.query | object | request.query.page |
request.body | string | the raw request body |
request.params | object | path 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 };
fetchfetch(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).
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.
sql.query & the vaultQuery 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.
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.
db.queryEvery 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.
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.
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.
storeA 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.
| Call | Does |
|---|---|
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 |
// Safe under concurrent endpoint calls — increment is atomic
const views = store.increment("views");
return { views };
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
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.
Hashing, HMAC (with a constant-time verify), random ids, and base64 — enough to verify webhooks and sign requests.
| Function | Returns |
|---|---|
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 |
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.
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.
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 };
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.
runner.targetA 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 };
| Call | Does |
|---|---|
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.
runner.claimPairingA 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.
channel & Response.streamPush 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.
| Call | Does |
|---|---|
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) |
// 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 });
// 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"));
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.
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 happens | Do 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.
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:
| Global | Notes |
|---|---|
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 / TextDecoder | UTF-8 only — correct for emoji and non-Latin scripts, not just ASCII |
setTimeout / setImmediate | Runs 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 |
setInterval | Accepted but never fires — repeating forever would hang the execution, and firing once would misrepresent it |
queueMicrotask | Runs immediately, same reasoning |
Event, EventTarget, AbortController | Minimal but functional — listeners fire on abort |
performance.now() | Milliseconds, backed by the clock |
self, global | Both 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.
console.log(...) — output is captured into the block's execution logs (visible in the logs viewer / via the API).JSON.parse(str) / JSON.stringify(obj) — standard, with a 64-level depth limit.| Resource | Limit |
|---|---|
| CPU time per block | ~5 seconds |
| Memory | 16 MB |
| Statements | 50,000 · read what's left with runtime.statementsRemaining() |
| Recursion depth | 50 |
| HTTP requests / execution | 10 (10s, 5 MB each) |
| SQL queries / execution | 20 (10k rows each) |
| Store ops / execution | 50 (key ≤ 256 chars, value ≤ 64 KB) |
| PBKDF2 / execution | 5 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):
require, no Buffer, no process, no filesystem modules. Arbitrary npm packages can't be installed; only the curated list is importable.eval / Function constructor; Object/Array/String/Number prototypes are frozen.fetch/XMLHttpRequest (SSRF-guarded).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.
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.
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:
| Step | What happens |
|---|---|
| Set up | The 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 & iterate | Run 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-on | A 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.
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.