Tips & recipes
Patterns that work against software written in 1998, and the reasoning behind the ones that look odd. The script API reference is the complete surface; this page is what to do with it.
Never write a sleep
A sleep encodes a guess about a machine you are not sitting at. It is too short on the morning the ERP is slow and too long every other day, and a script full of them takes ten minutes to do two minutes of work.
Every wait in the API waits for a fact: a window exists, a file stopped growing, an element became visible, the pixels stopped moving.
const win = ui.findWindow("Invoice Ledger"); // waits for it
const grid = ui.waitFor(() => win.find("automationId", "resultsGrid"),
{ timeout: 120000, describe: "the invoice grid" });Pass describe. The failure then reads "the invoice grid never appeared", which somebody can act on, rather than a timeout with no subject.
When nothing in the tree changes — an old application repainting its own canvas — wait on the pixels instead:
win.clickButton("Run report");
vision.waitForChange({ region: win.rect() }); // something happened
vision.waitForStill({ region: win.rect() }); // it finished happeningMatch a window by what it is
A title is localized, versioned, and sometimes empty. A class and a set of buttons are not.
const dialog = ui.findWindow(w => w.className === "#32770"); // any Windows dialogThe same script then works on a machine running the French build, and survives the vendor renaming a screen in the next release.
Check how many things your selector matched
find returns the first match and says nothing about the rest. On Windows' own Save As dialog, find("role", "edit") matches 46 elements — 44 of them cells in the file list, because the list exposes its columns as edits — and the first is one of those. That cell accepts a value, so setValue succeeds and changes nothing you can see.
log.info(win.findAll("role", "edit").length + " edits"); // while writing the selector
const box = win.find("automationId", "FileNameControlHost");Narrow with automationId or className until the count is one. The Selector panel in the live viewer reports the count for you.
Deal with interruptions before they happen
Software of this era interrupts constantly: a licence reminder, a "still there?" box, an update prompt. Automation that only handles them where you thought to look is a coin flip.
ui.guard(w => w.title.includes("Session expired"), (win) => {
win.clickButton("OK");
signIn();
});A guard runs before every poll of every wait, so the interruption is handled wherever it appears.
Extract only what changed
"Which rows are new since yesterday" is a hash per row plus a note for the next run.
const seen = new Set(job.state.get("hashes", []));
for (const row of csv.read(path)) {
const h = crypto.sha256(row); // an object hashes as canonical JSON
if (!seen.has(h)) { job.emit(row); seen.add(h); }
}
job.state.set("hashes", [...seen]);Field order does not change the hash, so a report that reorders its columns does not resend every row.
job.state is per machine on purpose: it has to work during the outages this service is designed for, and it is read inside loops where a network round trip would not be. For a watermark several machines share, keep it in Datastore.
It also survives a failed run. A run that got halfway did real work, and discarding its progress makes the retry redo something already done and already billed.
Drive the export, then read it
The useful primitive is usually not "give me these bytes" — it is "point Save As here".
win.setControlText(nameBox, job.outDir + "\\invoices.csv");
win.clickButton("Save");
fs.waitFor(job.outDir + "\\invoices.csv", { timeout: 120000 });
const out = csv.write(job.outDir + "\\open.csv");
for (const row of csv.read(job.outDir + "\\invoices.csv", { encoding: "cp1252" })) {
if (row.Status === "OPEN") out.write(row);
}
out.close();
artifact.putFile("open.csv", job.outDir + "\\open.csv");Three things this gets right that are easy to get wrong:
fs.waitForwaits for the file to stop growing, not to appear. An application writing a 4 GB export created the file in milliseconds and fills it for minutes; parse it too early and you get a short result nothing downstream can tell from a quiet day.- Stream, don't read.
csv.readandfs.lineshand back one record at a time, so a filter over a 2 GB export holds one row.[...csv.read(p)]defeats the whole point. - Old Windows software does not emit UTF-8. Reading a Windows-1252 export as UTF-8 does not fail — it corrupts accented names silently. Pass
{ encoding: "cp1252" }.
Anything under job.outDir is uploaded and then deleted, and the directory goes when the run ends. Files elsewhere are left alone, because a path outside is as often your own archive folder as a scratch file.
Fetching four thousand files
const items = ids.map(id => ({
url: "http://erp.internal/doc/" + id,
to: job.outDir + "\\" + id + ".pdf",
}));
const done = http.downloadAll(items, { limit: 8 });
log.info(done.filter(r => r.ok).length + " of " + done.length + " arrived");Not task.parallel. downloadAll does its parallelism inside the agent, so there is no second runtime, no function to recompile and no capture to get wrong. Use task.parallel when each item is work — sign in, page through, decide — and downloadAll when it is bytes.
The default limit is 4 and the ceiling is 16, and both are low on purpose. This runs on a PC in somebody's office, on an uplink nobody can scale: five hundred sockets do not finish sooner, they saturate the office.
Nothing here throws. A 404 comes back with an empty path, an unreachable host with an error, and both arrive beside the 3,999 that worked — which matters most on the call that took an hour.
Read a grid
const g = win.find("automationId", "resultsGrid").rows({ maxRows: 5000 });
if (g.truncated) log.warn("read " + g.rows.length + " of " + g.row_count);
for (const row of g.rows) job.emit({ invoice: row[0], customer: row[1] });Check truncated before shipping the result as complete: row_count is what the control claims, and a virtualized grid will happily say a hundred thousand.
Pick from a list by name, never by index — a position in a list somebody will add a row to — and never by typing, because autocomplete rewrites what you typed and can leave the box holding text that matches no entry at all.
const branches = combo.items();
combo.select(branches.items.find(i => i.text.includes("Aurora")).text);When there is no control tree
An application that draws its own grid, toolbar and text publishes nothing to read. That is what vision is for, and in this category it is not a rare case.
const hit = vision.find("Run Report", { region: win.rect() });
if (hit) mouse.click(hit.center);
vision.waitForText("Export complete", { timeout: 60000 });Try win.controls() and el.click() first — vision is slower, needs the window visible, and depends on what is actually on screen.
For a control with no text, match a picture. Templates match by score, so a theme, a DPI setting or one pixel of antialiasing does not break the match; when one does fail, bestImageMatch reports how close it got and where, which tells a wrong template from a moved window.
vision.findImage({ path: "C:\\templates\\save.png" }, { threshold: 0.9 });
vision.bestImageMatch({ path: "…" }).score;OCR uses the engine built into Windows and needs a language pack for the signed-in user (Settings > Time & language). To check a machine, run the agent's tests\ocr.js.
Getting a one-time code
A portal sends a code to somebody's mailbox. Everything else the agent does is outbound; this is the exception.
win.clickButton("Send code");
const code = job.waitForData("otp.dealer-42", { timeout: 300000 });Whatever already receives that message posts it in — most naturally a function, which has a public URL and does your parsing.
Having the script poll the mailbox instead is worse in three ways that all matter: the mailbox credentials end up on a desktop PC in a branch office, the script has to speak a vendor API that changes, and a two-second handover becomes a poll loop racing the portal's expiry timer.
Address by key, not by run: an SMS provider's webhook was configured months ago and cannot be told a run id that will not exist until tonight. Make the key specific — otp.dealer-42, not otp.
If the post comes back 202, do not ask the portal for a fresh code. The job may already have the first, and requesting another invalidates it — let waitForData's own timeout decide. The three outcomes.
Surviving a forced password change
Software in this category forces a password change mid-session, on a schedule nobody controls. A script can handle the dialog; what it cannot do is remember. Without the write-back the new password lives only in that run's memory, tomorrow's run signs in with the old one, and the account locks out until somebody drives to the site.
if (ui.window("Change your password")) {
const next = crypto.password(16);
win.find("name", "New password").setValue(next);
win.clickButton("OK");
env.set("PORTAL_PASSWORD", next); // blocks until saved; throws if it failed
}It blocks rather than returning and hoping, because by the time it runs the remote system's password has already changed. On a failure you still have the value: write it into an artifact.
Log in once, extract for weeks
const b = browser.open({ session: "dealer-42" });A named session maps to a browser profile that persists, so cookies, a login and a two-factor trust decision survive between runs. Without it, "log in once and extract four hundred pages over twenty nightly jobs" means twenty logins — and for a portal with 2FA, twenty humans.
Every selector call waits for the element to be visible, not merely to exist. A portal that renders its grid hidden and reveals it when data arrives — which is what a spinner is — has that grid in the DOM from first paint, so waiting for existence returns instantly and you read the previous page's numbers.
Long work is a chain
A backfill through two years should be a run per day, not one run for the year.
job.next({ date: nextDay(job.params.date) });Each link retries on its own, produces its own artifacts, and survives the machine rebooting. Only successful runs chain, so a failure stops the chain instead of billing for an infinite retry loop.
This is also why there is no wall clock by default. A backfill is finished when it runs out of history; killing it at an arbitrary hour loses the work without making anything safer. The cost ceiling, the stall detector and the livelock check are what make that responsible.
A clean machine for every run
Some work should not happen on a desk somebody sits at: it holds the foreground for an hour, or it leaves the application in a state the next person has to undo. A VirtualBox guest on the same PC is a second machine with its own desktop, and a snapshot is the reset.
The first time a script names a guest that is not there, ensure builds it from the fleet's Windows media and installs the agent into it — an hour that is not billed. After that the same call is a power button.
vm.stop("lab-1", { mode: "force" });
vm.restore("lab-1", "clean"); // yesterday's mess, gone
const g = vm.ensure("lab-1"); // on, and its agent connected
const run = g.run("nightly", { params: { date: day } });
if (run.status !== "done") job.emit({ guest: "lab-1", status: run.status, why: run.error });
g.stop();Take the snapshot after the guest has enrolled. Restoring rolls its credential back with everything else, and a guest holding no credential never connects again.
What comes back is deliberately thin — a status, an error, a cost. The guest ran the script, so its log, trace, screenshot and artifacts are on its run, which is where somebody looking at a 2am failure wants to be: the console links the two, and the screenshot is of the desktop the work happened on rather than the host's.
Deploy a script that babysits guests with --parallel. It spends its time waiting, and an exclusive run holds the host's desk lease for every minute of that.
Talking to a box that speaks no HTTP
const s = net.connect("10.0.0.50:23", { timeout: 10000 });
s.readUntil("login:");
s.write(env.HOST_USER + "\n");This is the socket, not the protocol. A terminal screen model — an 80×24 grid, a cursor, field positions — belongs in your own code, where you can fix an edge case the same afternoon rather than waiting for an agent release.
That rule decides a lot of what is here and what is not: anything in the agent can only be fixed by a signed release; anything in JavaScript is fixed in seconds. The platform ships what needs an OS API, a privilege or a round trip to us. Relational selectors, screen models and diff-sync frameworks are libraries.
Debugging a machine that is switched off
The run that fails at 2am has nobody watching, and by the time anyone looks the PC is off. Everything needed is captured at the moment of failure and uploaded with the run — the log, the trace, the machine and script context, and a screenshot.
Turn screen capture on for the one machine that is failing, from the Agents tab, rather than for the instance. Fleet-wide capture photographs every desktop in the building to debug one of them.
Three views answer three different questions:
| Question | Where |
|---|---|
| Why did this run fail? | Runs → Details. |
| Why is this machine unhealthy? | Agents → Agent log. A machine connected but doing nothing looks, from the run list, like a quiet night. |
| What is on its screen now? | Agents → View screen, with live access open. |
Test locally first
altengine-worker.exe run nightly.js --param date=2026-08-29 --trace trace.jsonNo control plane, no run record, same engine. The trace is written even when the run fails, and it is the same _trace.json a dispatched run uploads — so what you read locally is what support will read.