Writing Muxit scripts
A reference for AI assistants. It is self-contained: you can write a correct script from this page alone. Read How a script runs first and finish with the checklist.
A script is automation: a measurement run, a sweep, a monitoring loop, a data logger, an alarm. It uses devices that already have connectors, by name — connector("psu") is the connector defined in workspace/connectors/psu.js. If a device has no connector yet, write that first (Writing Connectors).
The deliverable
- One file,
workspace/scripts/<name>.js. Sub-folders are fine (workspace/scripts/tests/iv-curve.js). - Plain top-level JavaScript. No
export, noimport, nomain()— the file body is the program. A top-levelreturn value;ends the script and handsvalueback to whoever ran it. - Scripts in
workspace/startup/(top level only, alphabetical order) start automatically when the server starts, after connectors are up. Auto-start needs a Maker licence or higher. - There are no script parameters. Put settings in constants at the top of the file, read them from a connector (a Virtual store a dashboard can edit), or ask the operator with
ask.*.
You need the connector names and their properties
A script can only use names the connector actually has; anything else throws ReferenceError: Connector 'psu' has no property or method 'voltag'. Do not guess names. Ask the user for the connector file, or for the property and action list shown when the connector is expanded in Muxit's Connectors panel. If you are connected to Muxit over MCP, call list_connectors / get_connector_schema.
Know which names are properties (read with dev.x, write with dev.x = v) and which are actions (call with dev.x(…)). Reading an action without parentheses gives you a function, not a value — a camera's snapshot, for example, is an action: cam.snapshot().
How a script runs
const psu = connector("psu");
psu.voltage = 5; // write: blocks until the device has it
psu.output = true;
delay(500); // let it settle
const v = psu.measured_voltage; // read: a plain value, no await
log.info(`Measured ${v} V`);
return { v }; // optional result- Everything is synchronous. Property reads, writes, action calls,
delay,aiandaskblock the script until they finish and return plain values. Do not writeawait; it is unnecessary (it does no harm on a value, butawait dev.x = 5is a syntax error). - Loops must call
delay(ms). A loop without one spins a CPU core until stopped.delaycaps at 300 000 ms (5 minutes) per call. - There are no timers.
setTimeout,setIntervalandrequestAnimationFramedo not exist. Use awhileloop withdelay. - Stop is a hard kill. When the user presses Stop, the engine is interrupted wherever it is: code after the loop does not run and
finallyblocks do not run. Put setup before the loop, leave the hardware in a safe state at every iteration, and never rely on cleanup code at the end. A script that should run until stopped is simplywhile (true) { …; delay(ms); }— there is noscript.runningflag. - An uncaught error ends the script, logged with its line number. Wrap device calls you expect to fail in
try/catch. - Different scripts run concurrently; the same script cannot be started twice. The Free tier allows three running scripts at once.
Device access
const dev = connector("name"); // device("name") is an aliasconnector() throws if the connector does not exist or is disabled.
Reading dev.prop returns the latest polled value. Muxit polls every readable property the device backend declares every 200 ms, so a read costs no I/O and can be up to about 200 ms old. Properties the connector computes itself are recalculated on every read.
Writing dev.prop = value sends the value to the device and waits for the write to complete. The value you read back immediately afterwards is still the previous polled value; to confirm a setting, delay(300) and read again (or read a measured property after the device has settled). Assigning null, undefined, NaN or [] does nothing. A property the connector file defines with its own setter is the exception: the write is queued (at most one per 100 ms) and an error in it only reaches the server log.
Actions are called with positional arguments in the documented order:
dev.reset(); // no arguments
psu.save(3); // one argument
wh.notify("Run done", "12 points", "success"); // several
dev.moveTo({ x: 10, y: 20 }); // or one object with named argumentsFor a driver action, positional arguments are matched to the declared argument names, and an object is passed through as named arguments. Do not pass a lone array — dev.load([1, 2, 3]) is treated exactly like dev.load(1, 2, 3). Wrap it: dev.load({ values: [1, 2, 3] }).
What comes back is converted to plain JavaScript: numbers, strings, booleans, null, arrays and objects.
Globals
| Global | What it does |
|---|---|
connector(name), device(name) | Device proxy, as above. |
log.info(…), log.warn(…), log.error(…), log.debug(…) | Log to the script output panel. Any number of arguments, joined with spaces; plain objects are JSON-formatted, arrays are not — JSON.stringify them yourself. |
console.log, console.warn, console.error | Aliases of log.info / log.warn / log.error. There is no console.info or console.debug. |
delay(ms) | Sleep, up to 300 000 ms. Cut short when the script is stopped. |
timestamp() | Current time as an ISO 8601 UTC string. |
emit(event, data) | Send an event to other scripts. Each call is delivered, even with the same data twice. |
on(event, handler), waitFor(event, opts?) | Receive events. See Events. |
stream(connectorName, streamName, data) | Push data to dashboard widgets subscribed to that stream (a terminal, a chart, a canvas). |
say(text, opts?) | Show text in the chat panel, spoken aloud when the user has script speech on. opts.emotion: "excited", "serious", "whisper", … |
ask.confirm(msg, opts?), ask.choose(msg, choices, opts?), ask.text(msg, opts?) | Ask the operator and wait for the answer. See Operator interaction. |
ai(prompt, image?, options?) | One LLM call, returns the answer as a string. See AI. |
script.name | This script's name: its path under scripts/ without .js (tests/iv-curve), or startup/<name>. |
Standard JavaScript is available: Math, JSON, Date, Number, String, Array, Object, Map, Set, RegExp, Promise, Proxy, Reflect, globalThis, template strings, destructuring, classes and helper functions.
Not available: require, import, fetch, XMLHttpRequest, WebSocket, fs, process, Buffer, atob / btoa, TextEncoder, setTimeout, setInterval, eval, new Function, and every Node.js or browser API. Files go through a FileAccess connector; HTTP goes through a WebhookNotifier connector; scheduling is a loop with delay.
Events
Scripts can signal each other, and react to property changes, without polling.
emit("unit-done", { serial, pass }); // script A
on("unit-done", (u) => log.info(u.serial)); // script B: runs for every event
const u = waitFor("unit-done", { timeout: 60000 }); // or block for the next one- A bare name is another script's
emit()."state:<connector>.<property>"fires when that property's value changes; an unchanged value fires nothing."connector:<connector>.<event>"fires on every connector-configemit(), JS-driver__emitEvent, and every matching line of a LineText property withemit:, repeats included: use it for barcode scans."dashboard:<name>"fires each time a dashboard widget withconfig.emit: "<name>"is used; its data is{ value, user, widget }. A trailing*matches by prefix ("state:psu.*");meta.eventis the actual name. waitForoptions:timeout(ms),default(returned on timeout; without it the call throws an error withcode === "EVENT_TIMEOUT"),where(filter function). After the firstwaitFor(name), later events of that name are buffered until taken, so a loop aroundwaitForloses nothing while it is busy.- Events are delivered only while the script waits, in
delay(),waitFor()orask.*. A loop that never calls either never runs a handler. - A script that ends its top-level code with
on()handlers registered stays running until stopped. Don't add awhile (true)loop just to keep it alive. - Never use
on()for safety: a handler runs at the next wait, not instantly. Limits belong in the connector'ssafetyblock.
Files
The sandbox has no file system. Files go through a connector using the built-in FileAccess driver, sandboxed to a folder of the workspace (by default workspace/data/). A new workspace ships one named fileaccess — confirm the name with the user; if they have none, they add a connector { driver: "FileAccess", config: { basePath: "data" } } first.
const fa = connector("fileaccess");
const f = fa.openText("runs/iv-curve.csv"); // path relative to workspace/data/
f.write("time,voltage_v,current_a\n"); // overwrite (creates folders)
f.append([timestamp(), 5.0, 0.103]); // array → one CSV row, quoted as needed
f.append("a plain line\n"); // strings are written as-is
const text = f.read(); // whole file as a string
const lines = text.split("\n");
f.exists; // property: true / false
f.size; // property: bytes
f.rename("archive/iv-curve.csv"); // moves the file; the handle follows
f.delete(); // false if it was already gone
const tsv = fa.openText("out.tsv", "\t"); // another delimiter for array rows
const { files, dirs } = fa.list("runs"); // one folder, not recursive
fa.mkdir("runs/2026");
const img = fa.openBinary("snapshots/part.jpg");
img.write(connector("webcam").snapshot()); // binary handles read and write base64Paths are always relative to the sandbox folder: .. that escapes it is refused, and a leading / is not an absolute path ("/tmp/x.csv" lands in data/tmp/x.csv). Files are limited to 10 MB by default. Prefer read().split("\n") over readLines(), and avoid info(): both return .NET objects rather than plain JavaScript ones.
Operator interaction
if (!ask.confirm("Output will switch on at 12 V. Continue?", { timeout: 60000, default: false })) {
return "cancelled";
}
const port = ask.choose("Which fixture?", ["A", "B", "C"], { default: "A", timeout: 120000 });
const label = ask.text("Batch label?", { timeout: 300000 });- The script waits until a dashboard user answers.
timeoutis in milliseconds and at least 1000. Without it the script waits indefinitely.- On timeout the script gets
default; with nodefaultit throwsPROMPT_TIMEOUT. requireObserver: truepauses the countdown while no dashboard is open.
AI
const verdict = ai("Reply PASS or FAIL only: is 85 °C acceptable for a motor rated 90 °C?");
const seen = ai("Is the part seated in the fixture? Answer yes or no.", connector("webcam").snapshot());ai() returns a string (up to about 1024 tokens) and has no memory between calls and no tools. The second argument is a base64 JPEG, or an array of up to 8 images (strings or { image, label }). It uses the AI provider configured in Muxit: text works on every tier with a local model and needs Maker on the Muxit-managed service; any call with an image needs Maker. When the tier does not allow it, ai() throws — wrap it in try / catch if the script must keep going.
Safety
Writes and actions a script makes go through Muxit's safety gate, using the safety level the user has chosen and — on the Lab tier — the connector's safety block. (Connectors on drivers with no hardware to protect — Virtual, FileAccess, Webcam, Onvif, WebhookNotifier — skip the gate.)
- A blocked operation — out of range, or over a rate limit — throws an error with code
SAFETY_BLOCKEDand a message such aslimit.outOfRange; the hardware never sees it. - An operation that needs confirmation pauses the script until someone approves it in a dashboard. A denial, or no answer within 30 seconds of a dashboard being open, throws (
user.denied); with no dashboard open the script waits. - At the Observe level, ordinary writes are simulated without error and ordinary actions return
null.
So do not catch and ignore safety errors in a loop; let the script stop, or stop it yourself.
Patterns
Sweep with CSV logging
// workspace/scripts/iv-curve.js — step the PSU, log V and I.
const psu = connector("psu");
const csv = connector("fileaccess").openText(`iv/${timestamp().replace(/[:.]/g, "-")}.csv`);
const START_V = 0, STOP_V = 5, STEP_V = 0.25, SETTLE_MS = 400;
csv.write("set_v,measured_v,measured_a\n");
psu.voltage = START_V;
psu.output = true;
for (let set = START_V; set <= STOP_V + 1e-9; set += STEP_V) {
psu.voltage = Number(set.toFixed(3));
delay(SETTLE_MS); // settle + at least one poll cycle
csv.append([set.toFixed(3), psu.measured_voltage, psu.measured_current]);
}
psu.output = false; // a natural end; Stop would skip this
psu.voltage = 0;
return "done";Monitor with an alarm and hysteresis
const sensor = connector("thermocouple");
const alarm = connector("webhook"); // a WebhookNotifier connector
const LIMIT = 80, HYSTERESIS = 5;
let armed = true;
while (true) {
const t = sensor.temperature;
if (t === null || t === undefined) { log.warn("no reading"); delay(1000); continue; }
if (armed && t > LIMIT) {
alarm.notify("Over temperature", `${t.toFixed(1)} °C`, "error");
armed = false;
} else if (!armed && t < LIMIT - HYSTERESIS) {
armed = true;
}
delay(1000);
}Wait for a condition, with a timeout
function waitFor(check, timeoutMs, pollMs = 250) {
const end = Date.now() + timeoutMs;
while (Date.now() < end) {
if (check()) return true;
delay(pollMs);
}
return false;
}
const robot = connector("robot");
robot.moveTo({ x: 100, y: 50, z: 20 });
if (!waitFor(() => robot.idle, 30000)) throw new Error("move did not finish in 30 s");Hand values to a dashboard: write them to a Virtual connector's properties (connector("store").status = "Running step 3/10"); widgets bound to store.status update at once.
Common mistakes
| Mistake | Instead |
|---|---|
await dev.voltage, async function main(), main() | Plain top-level code; reads return values directly. |
setTimeout / setInterval | while (true) { …; delay(ms); } |
require("fs"), fetch(…) | A FileAccess connector; a WebhookNotifier connector. |
| Polling a value in a loop to notice that something happened | waitFor("state:<connector>.<property>"), or emit() / waitFor() between scripts. |
Cleanup after the loop or in finally | Safe state at every iteration; stop naturally with break when cleanup matters. |
Loop without delay | Always delay(ms) inside loops. |
| Reading a value right after writing it and expecting the new value | delay(300) first, or read a measured property. |
dev.snapshot for an action | dev.snapshot() — know which names are actions. |
connector("files") | Use the user's FileAccess connector name (fileaccess in a new workspace). |
| Passing an array as the only argument | Wrap it in an object with the argument name. |
| Invented property or connector names | Ask for the connector's schema. |
read_property(…), call_action(…) inside a script | Those are AI tools, not script functions. Use the proxy. |
Checklist
- [ ] The file goes in
workspace/scripts/(orworkspace/startup/to auto-start) and is plain top-level code: noimport,export,requireormain(). - [ ] Every connector, property and action name came from the user or a schema, and actions are called with
(). - [ ] No
await, nosetTimeout/setInterval, noon()/emit(), no Node or browser APIs. - [ ] Every loop calls
delay(ms). - [ ] Nothing important happens after the loop or in a
finallyblock; the hardware is safe at every point the script could be stopped. - [ ] Setpoints are within limits the user confirmed; outputs are not switched on, and axes not moved, without the user asking for it.
- [ ] Reads that must reflect a just-written value wait at least one poll cycle.
- [ ] File access goes through the user's FileAccess connector with paths relative to its folder.
- [ ] Anything you could not verify (connector names, units, safe ranges) is listed for the user.