Scripts are Block rules grown up. Where a block rule answers a matching request with a fixed canned response — a static Block, Drop, or Mock — a script is real JavaScript that runs on each matching request and response, so you can read and mutate the actual bytes: rewrite a request header, mock a JSON response computed per-request, patch one field of a real response body, or conditionally block based on what's inside.
It is the advanced evolution of the Mock action. A Mock returns one frozen string; a script returns whatever your code decides, this request, right now.
Admin-only. Scripts run arbitrary JavaScript inside the capture path, so authoring is gated behind the dedicated
scriptscapability (admin-tier, stricter than block rules). See Who can author and Roles & permissions.
The two authoring models
There are two ways to attach a script, for two different shapes of task:
| Model | Where you write it | When to reach for it |
|---|---|---|
| Standalone script | Settings → Scripts (global) · Devices → ‹device› → Scripts (per-device) | A named, reusable script with its own URL match, that may run onRequest, onResponse, or both. The normal way to work. |
| Inline Script action | On a Block rule — the action's 4th type, beside Block / Drop / Mock | A one-off bit of logic bolted onto a rule you already have. Reuses the rule's own method + pattern; no separate page. |
A standalone script carries its own matcher (name, URL pattern, method, phase) and one or both hook functions. An inline script is just a code string on a block rule — it borrows that rule's method and pattern and runs as a 4th action type.
Both live in the same settings JSON that block rules do, so everything you already know about scope and Realtime carries over: changes fan out to devices and the proxy without a reconnect.
What it is, in one diagram
A request flows forward through onRequest (you can mutate it, or synthesize a response and short-circuit); the upstream reply flows back through onResponse (you can mutate it before the client sees it).
If a hook returns nothing, the bytes pass through unchanged. Anything your script does is contained: if it throws, times out, or runs out of memory, the host quietly forwards the original bytes ("fail-open" — covered below), so a buggy script can never break a connection or reveal that traffic is being intercepted.
Using it
Create a standalone script
Open Settings → Scripts (global, applies to every device) or Devices → ‹device› → Scripts (one device only) and add a script. The fields:
| Field | Meaning |
|---|---|
| Name | A label for you — e.g. "Force feature flag on". |
| URL pattern | A host + path wildcard, the same syntax as block rules and breakpoints — e.g. api.doordash.com/v3/feature_flags*. Leave empty or * to match everything. |
| Method | Optional HTTP method (GET, POST, …). Omit it to match any method. |
| Phase | request · response · both — which hooks may run. An arming optimization: pick request if you only define onRequest. |
| Enabled | A Switch to toggle the script on/off without deleting it. |
| Code | The JavaScript — define onRequest, onResponse, or both. |
The editor
The code pane is a Monaco editor (the same engine as VS Code): syntax highlighting, error squiggles, and autocomplete for the whole req / res / ctx / busymate / Response contract — start typing req. or busymate. and the fields and methods pop up with inline docs. No server round-trip; the type definitions ship with the page.
Test it before it goes live (dry-run)
Beside the editor is a Test panel. Pick a real captured entry from your feed and run your script against it without touching live traffic. The panel shows:
- A before/after diff of method, URL, headers, and body for both phases.
- Any
ctx.log(...)lines your script wrote. - Any thrown error, with the line number.
There are two dry-run engines and they agree by design: a fast in-browser run for instant feedback, and a server-side run in the identical sandbox the live proxy uses — so "works in Test" provably equals "works live." Always Test against a representative entry before enabling a script.
Enable / disable & scope
- The Enabled Switch arms or disarms a script instantly — disabled scripts are skipped entirely (zero cost).
- Global scripts (
Settings → Scripts) apply to every device. Per-device scripts (Devices → ‹device› → Scripts) apply to one device only. - The effective set for a device is the global scripts plus that device's scripts (global ++ device), exactly like block rules. The per-device tab annotates how many global scripts are also in effect.
- Edits fan out over Realtime — no reconnect. (They apply to new connections; an in-flight keep-alive connection keeps the config it started with.)
The inline Script action on a block rule
If you just want a snippet of logic on a rule you already have, open the Blocks editor, add or edit a rule, and set its action to Script (the 4th option after Block / Drop / Mock). The status/headers/body sub-editor is replaced by the Monaco pane, and the script runs with the rule's own method + pattern. Same code contract as a standalone script — it just inherits the rule's matcher and rides the existing Blocks save path.
See that a script ran (inspector badges)
A live script is never invisible. Open any matching request in the inspector and you'll see a badge beside the existing resent / blocked badges:
| Badge | Meaning |
|---|---|
| scriptRan | A script matched and ran on this entry (and mutated or synthesized it, if it returned a verdict). |
| scriptError | A script threw or timed out on this entry — the original bytes were forwarded (fail-open). The error is recorded so a broken script is visible, not silent. |
| scriptTrace | The ctx.log(...) lines the script wrote on this entry, viewable in the inspector. |
These map to scriptRan / scriptError / scriptTrace fields on the entry, so even a script that times out in production shows up flagged — you'll never wonder "did it run?"
Example scripts
All four are copy-pasteable into the Code pane. Set the pattern / method to match the relevant host.
1 · Mutate a request header
Add a debug header and strip a conditional-request header so you always get a fresh response:
function onRequest(req) {
const headers = { ...req.headers, "x-debug": "1" };
delete headers["if-none-match"]; // or set it to null — both delete the header
return { headers }; // only "headers" is changed; everything else is untouched
}2 · Mock a JSON response
Short-circuit a matching request with a synthetic 200 and a JSON body — the upstream is never contacted. Use busymate.mock(...) or the Response.json(...) sugar:
function onRequest(req) {
if (req.path.startsWith("/v3/feature_flags")) {
return Response.json({ flags: { newCheckout: true } }, { status: 200 });
// equivalent: busymate.mock(200, { flags: { newCheckout: true } }, "application/json");
}
// no return → forward unchanged
}3 · Rewrite a field in a real response body
Let the request reach the server, then patch one field of the JSON it returns before the client sees it:
function onResponse(res, req) {
const ct = res.headers["content-type"] || "";
if (res.status === 200 && ct.includes("json")) {
const body = res.body.json(); // parse the decoded body
body.balance = 99999; // edit a field
return { body: JSON.stringify(body) }; // host recomputes Content-Length for you
}
}4 · Conditionally block
Block only when a condition holds — here, a POST whose body names a banned product — and let everything else through:
function onRequest(req) {
if (req.method === "POST") {
const body = req.body.json();
if (body && body.productId === "banned-sku") {
ctx.log("blocked banned-sku order");
return busymate.block(403, "Item unavailable");
}
}
// otherwise: forward unchanged
}The
ctx.log(...)line above shows up as the entry's scriptTrace in the inspector and in the Test panel — your debug breadcrumb.
Reference — the API contract
Both hooks are optional, both are synchronous (see no async), and one ES2020 subset runs byte-identically across every capture source. The full contract:
// Either or both. SYNCHRONOUS — no async / await / Promise / timers.
function onRequest(req) {
// req.method, req.url, req.host, req.path — strings, read-only views
// req.headers — plain object, lowercased keys, string values
// req.body — BodyProxy: req.body.text() / .json() / .bytes()
// req.bodyTooLarge — true if the body exceeded the 1 MB cap (body then omitted/truncated)
// req.deviceId — OPAQUE id, NOT a JWT, NOT the auth uuid
// ctx.state — the ONLY cross-hook channel (onRequest → onResponse); dies with the request
// ctx.log(...args) — capped debug log (≤ 16 lines / ≤ 2 KB); Test panel + entry.scriptTrace only
// RETURN:
// nothing / undefined → forward unchanged
// { method?, url?, headers?, body? } → mutate ONLY the listed keys; a null header value deletes it
// busymate.block(status?, body?, contentType?) → synthetic deny
// busymate.mock(status?, headers?, body?, contentType?) → synthetic success
// busymate.drop() → tear the flow down
// busymate.respond(status, body, headers) / Response.json(obj, init) → ergonomic synthetic
}
function onResponse(res, req) {
// res.status, res.headers, res.body (BodyProxy), res.bodyTooLarge
// res.request — the final, post-onRequest view of the request
// ctx.state, ctx.log available
// RETURN: nothing → unchanged · { status?, headers?, body? } → mutate before the client sees it
}req / res fields
| Field | On | Meaning |
|---|---|---|
method url host path | req | Read-only string views of the request line. |
status | res | The numeric response status. |
headers | both | Plain object — lowercased keys, string values. In a return mutation, a header value of null deletes that header. |
body | both | A BodyProxy: .text() returns the body as a string, .json() parses it, .bytes() returns a Uint8Array-shaped view. |
bodyTooLarge | both | true when the body exceeded the 1 MB cap — the body is then omitted/truncated, so check this before reading. |
deviceId | req | An opaque device id — not a JWT and not the account uuid. |
request | res | The final request view, after onRequest ran. |
ctx — per-request context
ctx.state— a plain object that is the only channel fromonRequesttoonResponsefor the same request (stash a timestamp, a flag, a captured value). It dies with the request — there is no state that survives across requests, by design.ctx.log(...args)— a capped debug log (≤ 16 lines / ≤ 2 KB). It surfaces only in the dashboard Test panel and on the entry'sscriptTrace. It cannot reach the network or write entries.
The busymate.* namespace
The only host-provided global, and it is frozen:
| Call | Effect |
|---|---|
busymate.block(status?, body?, contentType?) | Synthetic deny (defaults to 403). |
busymate.mock(status?, headers?, body?, contentType?) | Synthetic success (defaults to 200). |
busymate.drop() | Tear the flow down — the client sees a connection failure. |
busymate.respond(status, body, headers) | Ergonomic synthetic response. |
busymate.log(...args) | Same capped ring as ctx.log. |
busymate.uuid() | A safe id helper. |
Response.json(obj, init) | Sugar → busymate.mock with application/json. |
Every synthetic verdict is routed through the same response synthesizer as a Mock block rule, so it inherits the same guarantee: no x-busymate-* or framing headers leak — the synthetic response is indistinguishable from a real origin reply.
Return-value semantics
A hook's return value is interpreted as one of:
- Nothing (
undefined) → the bytes pass through unchanged. - A mutation object
{ method?, url?, headers?, body? }(request) or{ status?, headers?, body? }(response) → only the listed keys change; anullheader value deletes that header; abodychange makes the host recomputeContent-Length. - A
busymate.*verdict (block/mock/drop/respond) orResponse.json(...)→ a synthetic response short-circuits the flow.
Anything that fails the shape check — a wrong type, a returned Promise, an object that throws on access — is treated as malformed and fails open to the original bytes.
Synchronous only (no async/await)
Hooks are synchronous in v1. There is no async / await / Promise / setTimeout / callback — no event loop is exposed inside the sandbox, and a returned Promise is treated as a malformed result (→ fail-open). This is deliberate: it keeps the per-hook timeout a clean watchdog and the contract identical across engines. You cannot call your own server from a script (busymate.fetch does not exist — see security). Async is an explicit, additive future phase, never a quiet add.
Composition order
When several standalone scripts match the same flow they run as an ordered middleware chain: onRequest runs in array order (each script sees the previous one's mutation), and onResponse runs in reverse order — request flows forward, response flows back. An inline Script action on a block rule is evaluated first (inside the block-rule match) and short-circuits if it returns a verdict; standalone scripts run after block-rule enforcement.
Security model
Scripts are treated as untrusted code with assumed hostile intent, and the whole design subordinates to that. In plain terms:
- Sandboxed, no escape. On the proxy each script runs inside an isolated-vm sandbox — a genuinely separate V8 isolate, not a shared realm. There is no
require,process,module,fetch,XMLHttpRequest,fs,net, or timers inside it. A script cannot read the proxy's service-role key or any device secret (none exist in the sandbox), and cannot reach the network or filesystem from author-visible code — there is nobusymate.fetch, because it would be an exfiltration primitive. (The proxy keeps an internal, SSRF-guarded egress-allowlist for cross-host retargets, but it is not surfaced to script authors in the dashboard or MCP, so by default a script only ever mutates traffic matching its own pattern and cannot dial out.) - Fail-open, always. Any throw, timeout, out-of-memory, or malformed return makes the host forward the original bytes. A runaway or hostile script can never hang a connection, crash the proxy, or emit a malformed wire response that would reveal interception. The failure is recorded as
scriptErroron the entry (see badges) so it is visible, not silent. - Hard resource caps. Every hook is bounded: 50 ms per hook, 16 MB of memory per isolate, and a 1 MB body cap (over-cap bodies arrive flagged
bodyTooLarge, never materialized as an unbounded string). Hitting any cap → hard abort → fail-open. The second-order bound on latency is a 32-slot global concurrent-hook semaphore: only 32 hooks run at once across the whole proxy, and once that budget is saturated the proxy sheds to fail-open (forwards the original bytes rather than queueing) — so a flood of scripted connections can never pile work onto the event loop. There is no per-connection time wall; the concurrency budget is what keeps a script-chain from compounding latency. - Within the privacy chokepoint. The worst a malicious script can do is mutate or synthesize the bytes of requests/responses matching its own pattern, and write ≤ 2 KB to a debug log. It cannot emit
x-busymate-*headers or break HTTP framing (those are stripped/owned by the host after the script returns), touch other devices' traffic (scripts are device-scoped via the effective union), or persist state across requests.
Who can author (RBAC)
Authoring is stricter than block rules. A dedicated scripts capability section gates it:
scripts:viewto read scripts and run the server-side dry-run.scripts:editplus admin to write any script — standalone or an inline Script action on a block rule — and every write is confirm-gated. (Arbitrary in-MITM JavaScript is treated like the admin-only remote-browser-control tools.)
This is enforced at the database layer (a Postgres write-gate on the settings tables), so it holds no matter which surface writes — the dashboard, MCP, or a raw device token. In particular, a user with only blocks:edit cannot smuggle a script action into a block-rules write to run code. See Roles & permissions.
Per-source coverage
The API contract is frozen and runs byte-identically across every capture source — a script you write once behaves the same wherever it runs. But where a script can run, and how much of a request/response it can touch, depends on the capture engine underneath. The three sources differ in reach and in their body-size ceiling.
| Source | Where scripts run | Coverage | Body cap | Notes |
|---|---|---|---|---|
| proxy-server (PAC mode / non-iOS clients) | The Node MITM proxy in the capture path | All decrypted-HTTPS hosts (add them to your SSL-proxy list) + plain HTTP | 1 MB — bodies over the cap arrive flagged bodyTooLarge | The fullest engine — the reference implementation. Reach for it when you need everything below. |
cdp-connector (bmc) (Chrome) | Inside Chrome, via the DevTools Fetch domain | Whatever the controlled Chrome requests | 1 MB | Same-origin retarget only — a script can mutate or synthesize a response, but can't redirect a request to a different origin (a Fetch-domain limit). |
| iOS VPN mode (on-device tunnel MITM) | The NEPacketTunnelProvider network extension, on the device | Only hosts in your SSL-Proxying list — a host you haven't opted to decrypt is passed through untouched (host name only) | 256 KB — bodies ≥ 256 KB are bodyTooLarge and cannot be mutated | HTTP/1.1 only — the tunnel forces ALPN http/1.1, so HTTP/2 traffic is a gap. The tight body cap and fail-open under memory pressure both fall out of the extension's ~50 MB memory budget (iOS kills an over-budget network extension). |
iOS scripting is live (TestFlight build 177). The on-device engine is the vendored QuickJS-ng runtime — its public hard limits (
JS_SetMemoryLimit16 MB + a 50 ms interrupt watchdog) keep a runaway script contained well inside the extension's ~50 MB budget, verified by an on-device jetsam gate (loop + allocator adversaries both contained, footprint flat ~5 MB, the tunnel never dropped). iOS VPN-mode is still the most constrained source — the 256 KB body cap and HTTP/1.1-only reach below are real — but it is fully shipped and fails open under pressure rather than killing the tunnel.One caveat: a newly added or edited script reaches a running tunnel only after a VPN off→on cycle (a known extension realtime-receive limitation, tracked in issue #77). The engine itself is fully live — toggle the VPN after saving a script.
When iOS VPN mode can't do it — use PAC mode
If you need scripting on something the on-device tunnel can't handle, route that device through the proxy-server (PAC mode) instead — same scripts, the fuller engine. Switch when you need either of:
- Bodies larger than 256 KB — the proxy's cap is 1 MB (vs 256 KB on iOS), so larger payloads stay mutable.
- A host you'd rather not MITM on-device — PAC routes the device's traffic to the proxy, so the decryption + scripting happens off-device.
Switch a device's connection mode to PAC and the same scripts apply, with the proxy's full reach and the higher body ceiling.
HTTP/2 is a gap on both sources, not just iOS. The proxy MITM also advertises ALPN
http/1.1(h2-capable clients downgrade), exactly like the on-device tunnel — so switching to PAC does not add HTTP/2 scripting. HTTP/2-native flows are out of scope everywhere until the framer lands.
Roadmap
The scripting engine is now complete across every capture source — proxy-server, cdp-connector (bmc), and iOS VPN mode — plus full read/write parity over the dashboard and MCP (get_scripts / set_scripts_global / set_scripts_device / dry_run_script). Issue #79 (the cross-source umbrella) is closed: the final surface, iOS, shipped in TestFlight build 177 on the vendored QuickJS-ng engine. The frozen API contract means a script you write today runs unchanged on every source above. Remaining polish: live script propagation to a running iOS tunnel still needs a VPN cycle (#77), and HTTP/2 scripting awaits the h2 framer (a gap on both MITM sources).
Next
- Block rules — the simpler, fixed-response cousin that scripting evolves from.
- Breakpoints & resend — pause and hand-edit a request instead of scripting it.
- Inspect requests — where the scriptRan / scriptError / scriptTrace badges appear.
- Roles & permissions — grant the
scriptscapability to a teammate.