← Automation API

Docs

Script API reference

Every function in scope inside an automation script. See the Automation API for deploying, scheduling and pricing, and tips & recipes for the patterns.

Three rules that apply everywhere:

  • Everything is synchronous. No promises, no await.
  • Waits are for facts — a window exists, a file stopped growing, an element became visible. sys.sleep exists; you almost never need it.
  • An error is thrown; a status is data. An HTTP 500, a non-zero exit code and a failed OCR match are values you branch on.

job

The job object
job.paramsThe run's input. A key that was not passed reads undefined.
job.nameThe script's deployed name.
job.outDirThis run's output directory. Anything written here is uploaded, then deleted. Empty string in a local run.
job.result(value)The run's answer: one JSON value, up to 64 KB, returned on the run as result. Kept when the run fails. The last call wins. A script's last expression is not a result.
job.emit(record)Append one JSON record to _emitted.ndjson.
job.next(params)Queue another run of this script with different input. Only a successful run chains.
job.stateA store that survives between runs on this machine.
job.waitForData(key, opts?)Block until something outside sends a value.

job.state

const since = job.state.get("watermark", "2020-01-01");
// … do the work …
job.state.set("watermark", today);
job.state
get(key, default?) / set(key, value)Any JSON value.
delete(key) / keys() / clear()
flush()Write now. Otherwise it saves on its own and when the run ends.

Per machine, not per fleet. Survives a failed run. Bounded at 256 KB and 2,000 keys.

job.waitForData

win.clickButton("Send code");
const code = job.waitForData("otp.dealer-42", { timeout: 300000 });
win.find("name", "Code").setValue(code.value || code);
Inbound data
job.waitForData(key, {timeout, default}?)Blocks. Default 5 minutes, maximum 2 hours. With no default, a timeout throws.
job.peekData(key)Non-blocking. null if nothing has arrived.
job.dataUrl(key)A URL scoped to this run and this key, carrying its own capability token.

A value that arrives before you ask for it is held, not dropped. JSON arrives as an object, anything else as a string.

Sending the value

Three ways in. All three address the key, which does the routing: a value reaches every job running on the instance, and only one that asks for that key sees it. script and agent_id narrow it further. Posting when nothing is running is a 409.

From a function:

export default {
  async fetch(req, env) {
    const body = await req.json();
    const code = /\b(\d{6})\b/.exec(body.Body)?.[1];
    if (!code) return new Response("no code in that message", { status: 200 });

    await env.automation.deliver({ instance: "fleet" }, "otp.dealer-42", code);
    return new Response("ok");
  }
}

Over the API, with an organization API key:

curl -X POST https://api.altengine.net/v1/automation/fleet/data/otp.dealer-42 \
  -H "Authorization: Bearer $ALTENGINE_API_KEY" \
  -d '"123456"'

Or with the URL job.dataUrl(key) returns, which does one thing to one run and dies with it.

All three answer with what the machine confirmed. A value written to an open socket that goes unacknowledged is neither delivered nor refused: deliver names those runs in undetermined and the API answers 202. Do not send a replacement — the job may already have it, and a second one-time code invalidates the first. All three outcomes.

env

Credentials set on the instance and handed to the run. Values are write-only from outside — a read gives names — and travel with the dispatch, so a run that starts during an outage can still sign in.

env
env.NAMEThe value. Uppercase by rule.
env.get(name, default?) / env.has(name) / env.names()
env.set(name, value)Persist a new value for the next run. null deletes. Blocks until saved, and throws if the save failed.

env holds only what the run was given, never the machine's own environment.

log

log.debug / info / warn / error. console.log maps to info. Lines are tailed live and uploaded as _log.ndjson when the run ends.

sys

sys
sys.exec(exe, args?, opts?){ok, code, stdout, stderr, timedOut, truncated}. Options: timeout, cwd, input, env, encoding.
sys.launch(exe, …args)Start something and carry on.
sys.kill(pid)
sys.platform"windows", "linux" or "darwin".
sys.desktop()"available", "locked" or "none" — the same word the console shows against the machine.
sys.sendKeys(…)Types into whatever holds the foreground. Prefer win.sendKeys.
sys.screenshot(rect?)PNG bytes.
sys.monitors()[{rect, scale, primary}] — each screen, in physical pixels; scale is a percent. Desktop lanes only.
sys.keepAwake(bool)Runs already hold the display awake; this is for the gaps.
sys.sleep(ms)

Arguments are an array, never one command string. For a shell, name it: sys.exec("cmd", ["/c", …]).

A timeout kills the program and everything it started. Only a program that could not start throws; a non-zero exit is data, and a timeout still returns whatever was printed.

A script with a headless half and a desktop half asks sys.desktop() first: finding out from a failure costs the run's whole desktop timeout.

const rows = csv.read(exportPath);          // works whatever the machine is doing
http.fetch(url, { method: "POST", body: rows });   // an object body is sent as JSON
if (sys.desktop() !== "available") job.emit({ uploaded: rows.length, drove: false });
else { /* drive the application */ }

"none" means this run has no desktop at all — a headless lane, or a build without desktop automation. It is not the same as locked, and neither one can drive a window.

fs and csv

Covered on the Automation page. Two additions:

Archives and disk
fs.zip(sourceDir, archivePath)Build an archive from a directory.
fs.unzip(archive, targetDir, {overwrite}?)Refuses entries that would land outside targetDir.
fs.zipList(archive)Names and sizes, without extracting.
fs.diskFree(path?){free, total, used, path} in bytes.

crypto

crypto
crypto.md5 / sha1 / sha256 / sha512(input)Hex. A string, bytes, or an object.
crypto.hash(algo, input)
crypto.hashFile(path) or (algo, path)Streams, so file size does not matter. Defaults to sha256.
crypto.hmac(algo, key, input)
crypto.equal(a, b)Constant time.
crypto.base64 / fromBase64 / hex / fromHexThere is no btoa here. The from* pair returns bytes.
crypto.randomBytes(n) / randomHex(n) / uuid()
crypto.password(length?)One character from each class, and no 0/O/1/l/I. Default 16, range 8–128.
crypto.pbkdf2(algo, password, salt, iterations, length)Hex. sha1 is allowed — it is .NET's Rfc2898DeriveBytes default.

Hashing an object encodes it as JSON with sorted keys, so field order does not change the hash.

time

This runtime has no Intl, so Date.toLocaleString ignores a timeZone and uses the machine's. Use time for anything zone-dependent.

const day = time.format(time.now(), "date", "America/Chicago");
const start = time.startOfDay(time.now(), "America/Chicago");
const back = time.addDays(start, -30, "America/Chicago");
time
time.now() / time.zone()Milliseconds, and the machine's zone.
time.format(ms, layout?, zone?)Layouts: date, datetime, rfc3339, time, us, stamp, or a Go layout.
time.parse(text, layout?, zone?)
time.parts(ms, zone?){year, month, day, hour, minute, second, weekday, yearDay, zone, offsetMinutes, millis}.
time.startOfDay(ms, zone?)
time.addDays(ms, n, zone?) / addMonthsCalendar arithmetic, so a daylight-saving day is still one day.
time.daysBetween(a, b, zone?)Whole days, by calendar.

addMonths clamps: one month after 31 January is 28 February. An unknown zone name throws rather than falling back to local.

http

const r = http.fetch("http://erp.internal/api/invoices", {
  method: "POST",
  headers: { authorization: "Bearer " + env.ERP_TOKEN },
  body: { since: day },
});
if (!r.ok) throw new Error("ERP said " + r.status + ": " + r.text());
for (const row of r.json().rows) job.emit(row);

{status, ok, headers, truncated, text(), json()}. Options: method, headers, body, timeout. A body that is not a string or bytes is sent as JSON, and sets the content type unless you set your own.

Only a request that could not be made throws. There is no outbound allowlist. Responses over 32 MB are truncated and say so.

http.download

A response body that goes to disk instead of through memory.

const r = http.download("http://erp.internal/export?day=" + day,
                        job.outDir + "\\invoices.zip");
if (!r.ok) throw new Error("the export said " + r.status + ": " + r.excerpt);
artifact.putFile("invoices.zip", r.path, { remove: true });
Downloading to disk
http.download(url, dest, opts?){status, ok, headers, path, bytes, excerpt}. Options are fetch's.
http.downloadAll(items, {limit}?)items is [{url, to, method?, body?, headers?, timeout?}]; any other field is refused. Results in input order.
  • No size cap. The bytes never pass through memory, so the disk is the bound.
  • Atomic. The body goes to a temp file beside dest and is renamed once all of it has arrived and been flushed. A server that sent fewer bytes than it declared throws, rather than leaving a short file.
  • A non-2xx writes nothing and does not throw: path comes back empty, and excerpt holds the first 8 KB of the error body — the portal's sign-in page, the API's complaint.
  • timeout bounds how long a transfer may go without progress, not how long it may take. Default 5 minutes; a 20 GB export runs for as long as it keeps arriving.
  • The response headers come back: content-disposition for its real name, content-type for an extension the URL does not have, etag for whether to fetch it again at all.

downloadAll's limit defaults to 4 and is clamped to 16. A transfer that failed outright is a result carrying error, not an exception. The bulk pattern is in tips & recipes.

db

SQL Server, PostgreSQL and MySQL.

const c = db.connect("sqlserver", "sqlserver://user:[email protected]:1433?database=DEALER");

for (const row of c.query("SELECT ro, total FROM repair_order WHERE closed > @p1", [since])) {
  out.write(row);
}
c.close();

Drivers: sqlserver (mssql), postgres (postgresql, pgx), mysql (mariadb). db.drivers() lists them. connect takes {timeout} and verifies the connection before it returns, so a wrong password fails there rather than at the first query.

db
c.query(sql, params?, {timeout}?)An iterator — one row at a time, so a million-row report holds one. [...c.query()] defeats the point.
c.queryAll(sql, params?, {timeout, max}?)An array. Refuses past max (10,000).
c.queryRow(sql, params?, {timeout}?)The first row, or null.
c.exec(sql, params?, {timeout}?){rowsAffected, lastInsertId}; either is null where the driver cannot say.
c.transact(fn)One transaction. See below.
c.close() / c.closed() / c.server

Placeholders are the driver's own — @p1 for SQL Server, $1 for PostgreSQL, ? for MySQL. Parameters are always bound. An array is positional; an object is named, which is how a stored procedure is called:

c.queryRow("EXEC sp_Login @UserName, @Password, @CheckCurrentUsers", {
  UserName: "FOX",
  Password: crypto.fromHex(crypto.pbkdf2("sha1", pw, salt, 1000 + "FOX".length, 20)),
  CheckCurrentUsers: false,
});

An object or array passed as a value is refused rather than serialized; pass JSON.stringify(it) if that is what you mean.

Coming back: binary columns are hex (crypto.fromHex for the bytes), a uniqueidentifier is a lower-case dashed GUID and goes back as a parameter in that form, dates are RFC 3339 strings, NULL is null, and a duplicated column name is renamed the way csv.read renames a duplicated header.

Transactions

A transaction is a scope. There is no begin/commit pair.

const roNumber = c.transact((tx) => {
  tx.exec("INSERT INTO repair_order (vin, opened) VALUES (@p1, @p2)", [vin, now]);
  return tx.queryRow("SELECT SCOPE_IDENTITY() AS ro").ro;
});

It commits when the function returns and hands back what it returned; it rolls back and re-throws if the function throws. tx.rollback() inside is for deciding not to keep the batch — it returns normally rather than throwing. The transaction is finished once the function returns, and says so if you use it afterwards. Nesting transact on one connection is refused: these databases do not nest transactions.

There is no default query timeout. The bound is the run's own budget; pass {timeout} where you know better. An open iterator holds a connection until it is exhausted or you break.

net

A raw TCP socket, for what speaks neither HTTP nor SQL.

const s = net.connect("10.0.0.50:23", { timeout: 10000 });
s.readUntil("login:");
s.write(env.HOST_USER + "\n");
s.readUntil("Password:");
s.write(env.HOST_PASSWORD + "\n");
const screen = s.read({ timeout: 2000 });   // "" on timeout
s.close();

{address, write(data), read(opts?), readUntil(text, opts?), close(), closed()}. connect takes {timeout, tls, insecure, encoding}. read takes {timeout, max} and returns "" when it times out; readUntil takes {timeout} only and throws on a timeout or an early close, with whatever had arrived in the message.

closed() is a call, not a property — if (s.closed) is always true. readUntil matches on the decoded text, not the bytes, and the delimiter is included in what it returns. A socket you forget to close is closed when the run ends.

task

Work that runs off the script's own thread. Each worker gets its own runtime, and every rule below follows from that.

const out = task.parallel(accounts, (a) => {
  const r = http.fetch(a.url, { headers: { authorization: a.token } });
  return { id: a.id, status: r.status, rows: r.ok ? r.json().rows.length : 0 };
}, { limit: 8 });

for (const o of out) {
  if (o.ok) job.emit(o.value); else log.warn(o.error);
}
task
task.parallel(items, fn, {limit, failFast}?)[{ok, value, error}] in input order. limit defaults to 4 and is clamped to 16.
task.run(fn, params)Starts one function and returns a handle immediately.
t.wait() / t.done() / t.cancel() / t.namewait() returns the value, or throws what the task threw.

task.run is for the other shape — start something long and carry on:

const t = task.run((p) => http.download(p.url, p.to).bytes,
                   { url: exportUrl, to: job.outDir + "\\year-end.zip" });
// … drive the application meanwhile …
log.info(t.wait() + " bytes");

There are no closures. The function is taken by source and recompiled in the worker, so it sees its argument and the globals and nothing else — a variable captured from the surrounding script is refused rather than silently reading as undefined. Pass it in as part of the argument.

For the same reason only a plain or arrow function written in your script can be a task: a built-in, a bound function and a method shorthand have no source to rebuild from. Arguments and return values cross as JSON, so a window, an element or a file handle can be neither passed nor returned.

What a task can reach
Availablehttp, fs, csv, net, crypto, time, artifact, log, sys.sleep, sys.desktop() (always "none"), env for reading, and job.params / name / outDir / emit / state / worker.
Refused, with the reasonui, mouse, clip, vision, browser, vm, user, the rest of sys, job.next, job.waitForData, env.set, and a task inside a task.
Absentdb and dom — a task that names either is refused as a captured variable. job.result, job.peekData and job.dataUrl are undefined.

Make the refused calls in the main script, with what the task returned.

failFast is off by default. Turned on, it stops handing out work and cancels what is in flight; the results already collected still come back.

To fetch files, use http.downloadAll instead: its parallelism is inside the agent, with no second runtime and no source to recompile.

ui

Finding and driving windows. Every call that waits takes an optional timeout, defaulting to the instance's action timeout.

ui
ui.findWindow(titleOrFn, timeout?)Waits. Matches a title substring, or a predicate over {title, className, process, handle, visible}.
ui.findWindows(…)Every match.
ui.window(…) / ui.windows(…)The same, but now — null or [] rather than waiting.
ui.listWindows()Everything on the desktop.
ui.waitFor(fn, {timeout, describe}?)Wait for anything. describe becomes the error message.
ui.guard(fn)Run fn before every poll of every wait. It takes no arguments, does its own looking, and returns true when it handled something. Returns a handle: { stop(), active() }.
const g = ui.guard(() => {
  const expired = ui.window("Session expired");
  if (expired) { expired.clickButton("OK"); signIn(); return true; }
  return false;                       // nothing of mine was on screen
});
// …later, when that phase is over: g.stop();

Returning true also resets the enclosing wait's deadline, so time spent clearing modals out of the way does not count against it.

A window

Window methods
focus() / isForeground()focus() waits and confirms.
title() / rect() / pid / processtitle() re-reads now.
scale()The display scaling of the screen the window is on, in percent.
typeText(text)Posts characters to the focused control.
sendKeys(…)Keys and chords: {ENTER}, {F4}, ^{c}, +{TAB}, %{F4}.
find(by, query, timeout?)by is name, role, automationId or class. Returns the first match, waiting until it appears — the fleet's action timeout by default (30s). Pass milliseconds, or {timeout: ms}.
findAll(by, query, timeout?)Every match, without waiting unless you pass a timeout — then it waits for the first one and returns them all. See below.
tree(maxDepth?, maxNodes?)The accessibility tree as JSON.
controls() / setControlText(handle, text)Classic Win32 children, with handles and rectangles.
controlItems(handle, {max}?) / selectControlItem(handle, text)A classic combo box or list box.
buttons() / clickButton(text)The mnemonic & and case are ignored, so "OK" finds "&OK".
maximize() / minimize() / restore() / isMaximized() / isMinimized()
exists() / waitUntilClosed(timeout?) / close()close() is a request a dialog can decline.

find waits and findAll does not. On a screen that fills in — a grid populated after the query returns — findAll gives whatever exists at that instant, so a slow one yields nothing or half a table with no error anywhere. Pass a timeout when the screen is still loading and it waits for the first match before returning them all.

typeText carries the character rather than a key event, so an application watching for key presses will not see it. Use sendKeys for keys and typeText for text.

A modifier applies to a brace group, so "^{s}" is Ctrl+S and "^s" is refused. That is what keeps "+1" and "100%" ordinary text rather than keystrokes.

Naming a file in a Save dialog

On Windows 11 the file name field is a ComboBox with an Edit inside it, and the dialog reads neither — it tracks the name through real key events. setControlText, setValue and typeText all change what is on screen, all read back correctly, and the file is then written under the prefilled name.

const dialog = ui.findWindow((w) => w.className === "#32770" && /save as/i.test(w.title), 15000);
dialog.sendKeys("%{n}");        // the "File name:" accelerator
dialog.sendKeys("^{a}");        // replace what is prefilled
dialog.sendKeys(fullPath);      // real keystrokes, not typeText
dialog.clickButton("Save");     // Enter would take autocomplete's suggestion
dialog.waitUntilClosed(10000);
if (!fs.exists(fullPath)) throw new Error(`saved somewhere else: ${fullPath}`);

The dialog closing tells you a save happened, never where, so check the path.

An element

Element methods
name / role / automationId / className / enabled
click()Through the accessibility API, not the mouse.
setValue(text) / text()setValue is atomic.
rect() / center() / children()
rows({maxRows, maxCols}?){rows, row_count, column_count, truncated}.
items({max}?) / select(text)A list, combo box or tree, and what is selected.
expand() / collapse()Some applications only populate a list once it is open.
const grid = win.find("automationId", "resultsGrid");
const g = grid.rows({ maxRows: 5000 });
if (g.truncated) log.warn("read " + g.rows.length + " of " + g.row_count + " rows");
for (const row of g.rows) job.emit({ invoice: row[0], customer: row[1] });

row_count is what the control claims, which can be far more than you read — check truncated. A control that draws its own table publishes no structure, and rows() says so rather than returning something empty.

mouse

mouse.position(), move(pt), click(pt?, button?), doubleClick, down, up, drag(from, to), wheel(notches) — which scrolls wherever the cursor already is, so move first. Points are {x, y} in screen coordinates; el.center() and vision.find().center both give you one. Travel is interpolated.

Coordinates are physical pixels on every monitor, at any display scaling: rect(), el.rect(), vision results, screenshots and clicks all agree. A fixed offset you measured on a 100% screen multiplies by win.scale() / 100; prefer selectors and center().

Input that Windows would drop is an error, not a success. Mouse and keyboard calls throw instead of reporting success when:

  • a program running as administrator is in front or under the pointer — input blocked: …. The agent cannot drive an elevated program; run it without elevation.
  • a UAC prompt, the lock screen or the sign-in screen has the screen — input blocked: another desktop has the screen. The console lane is how a script answers those.
  • the pointer did not reach its target, because a person moved the mouse or the point is off-screen — the pointer did not arrive: …. A point a few pixels past a screen's edge still lands when a window's frame reaches it, as a maximized window's does.

If that happens while a button is held, a drag-and-drop in progress is cancelled with Esc (the drag was cancelled: …), a standard menu or drop-down list is closed without choosing (the menu was cancelled: …), and any other button is released where the pointer is (the button was released at (x,y): …). win.typeText into an elevated program's window throws even when it is not in front.

clip

clip.read() / clip.write(text).

vision

Covered on the Automation page. The surface:

vision
vision.text({region}?)Every word: [{text, line, confidence, rect, center}].
vision.find(phrase, opts?){text, rect, center}, or null when absent. {region, exact}.
vision.findAll(phrase, opts?)An array of the same, [] when absent. {region, exact}.
vision.waitForText(phrase, opts?)The match, as find. Throws on timeout, and says what was readable — the words only where failure screenshots are on, otherwise how many boxes. {region, exact, timeout}.
vision.findImage(template, opts?)[{score, rect, center}], best first, up to max (default 16) — [] when absent, so test .length. Template: {path}, {base64} or bytes. {threshold, region, max}; threshold defaults to 0.9.
vision.bestImageMatch(template, opts?){score, rect, center} of the best candidate findImage's search scored, whatever its score — the score a findImage miss reports, at the same cost. Never null. {region}.
vision.waitForStill(opts?)Returns nothing — it waits for the pixels to settle. {region, timeout, stillFor, tolerance, minChange}.
vision.waitForChange(opts?)The rectangle that moved: {x, y, w, h}. {region, timeout, tolerance, minChange}.

A text or image match carries rect and center in screen coordinates, even when you passed a region. The two wait calls are the exceptions above: one returns nothing, the other a bare rectangle.

A search that misses says how close it came, and where, in its _trace.json step. A failed run's bundle keeps the last five searches: open a _vision-NN-template.png beside its _vision-NN-seen.png to tell a redesigned icon from a window that moved.

browser

Drives the browser already installed on the machine; nothing is downloaded.

const b = browser.open({ session: "dealer-42", headless: true });
b.goto("https://portal.example/login");
if (b.exists("#username")) {
  b.type("#username", env.PORTAL_USER);
  b.type("#password", env.PORTAL_PASSWORD);
  b.click("button[type=submit]");
}
b.waitFor("#invoices");
const file = b.download(() => b.click("#export-csv"));
for (const row of csv.read(file)) job.emit(row);
b.close();
browser
browser.open(opts?){session, headless, timeout, url, width, height, proxy, path, args}. A named session keeps its profile between runs; without one it is discarded at the end of the run.
browser.available() / browser.path()
goto(url) / url() / title()
waitFor(sel, {timeout, visible}?) / exists(sel)
text(sel) / texts(sel) / attr(sel, name) / html(sel?)
click(sel) / type(sel, text) / select(sel, optionText) / press(key)select picks the first option whose visible text contains optionText, case-sensitively — never its value. No match throws. press takes Enter, Tab, Escape, the arrows, PageUp/PageDown, Backspace, Delete.
eval(js)Runs in the page and returns JSON.
screenshot() / pdf()Bytes, ready for artifact.put. The screenshot is the whole page; it takes no selector.
download(fn, {timeout}?)Runs fn, waits for the file to finish, returns its path.
cookies()The page's cookies, to hand its session to http.fetch.
setHeaders(obj)The other direction: extra headers on the requests the PAGE makes from now on.
profile() / downloadDir() / close()Downloads land in job.outDir when there is one.

Every selector call waits for the element to be visible, not merely to exist. A browser you forget to close is closed when the run ends.

dom

Reads a native application's embedded browser — a Wails, Electron or WebView2 app whose interface is a web page — as a document rather than a picture. It returns the same page object browser.open does, so every selector, wait and form call above carries over.

// Start it if it is not already up. Electron and CEF apps take the switch themselves.
if (!dom.available({ port: 9222 })) {
  sys.launch("C:\\Apps\\dealer.exe", "--remote-debugging-port=9222");
}
const page = dom.attach({ port: 9222, match: "Repair Orders", timeout: 60000 });

page.waitFor("#ros");
const rows = page.eval(`(sel) => [...document.querySelectorAll(sel + " tr")]
    .map(tr => [...tr.cells].map(c => c.innerText.trim()))`, "#ros");

const w = csv.write(job.outDir + "\\repair-orders.csv");
for (const r of rows) w.write(r);
w.close();

page.click("//button[contains(., 'Post Invoice')]");
page.close();   // lets go — does not close their application
Call
dom.detect(title?)Start here. Which windows on this machine are web pages: {title, process, kind, hint}, plus port when one is already reachable. kind is chromium, browser or ie.
dom.available({port}?)Is anything speaking this protocol. Ask before failing.
dom.pages({port}?)[{title, url, type}] — what to look at when attach matches nothing.
dom.attach({port, match, timeout, host}?)The page. match is a substring of the title or the url, case-insensitively.
  • The port is not something a script can create, and how one is opened depends on what the application embeds. Electron and CEF apps take --remote-debugging-port=9222 on their own command line, so launching the application yourself is enough. A WebView2 app (Wails, Tauri, MAUI, WPF) filters that switch out of its documented environment variable, and only the machine's WebView2 policy for that executable works:
    reg add "HKLM\SOFTWARE\Policies\Microsoft\Edge\WebView2\AdditionalBrowserArguments" ^
            /v "theirapp.exe" /t REG_SZ /d "--remote-debugging-port=9222" /f
    Name the one executable, never *: the wildcard opens a debug port for every WebView2 app on that machine, which on Windows 11 includes Widgets, Copilot and the search host. It is HKLM, so it needs a maintenance-lane script, and the application has to be restarted afterwards.
  • Letting go does not close their application. close() and the end of the run disconnect and leave it running.
  • Arguments to eval are passed, not pasted. They arrive as the function's parameters, so a value carrying a quote is a value rather than a syntax error inside somebody else's application. And read a grid in one eval: every selector call is a round trip, which on a table is the difference between a second and a minute.
  • Two pages and no match is refused, not guessed at. The refusal names the pages. match tests the url as well as the title, which separates two windows of one application that share a title.
  • timeout covers a page appearing, not just the port answering. An application opens its debugging port while it is still starting, so a launch-and-attach that asked once would be told there are no pages by a program seconds away from showing one.
  • Starting an Electron app with ELECTRON_RUN_AS_NODE in its environment breaks it in a way that looks like a refusal. The binary becomes a plain Node interpreter — no window, no renderer, no port. Clear it before launching one.
  • Try ui first — whether it can read a web view is not one answer. A WebView2 application publishes not one element of its page, an Electron one usually publishes its whole document, a Qt WebEngine one publishes its document too, and an old application built on the Internet Explorer control always does. So WebView2 is the case that needs a port, and an embedded browser is not otherwise opaque. dom.detect() says which windows are web pages, and a find that comes back empty on one explains itself and names the fix.
  • A Qt WebEngine window is not reported by dom.detect(). Qt paints the page into the window itself, so nothing about the window says a browser is in there. Read it with ui, which works on it.

vm

The VirtualBox guests this machine hosts. A host switches a guest on and asks for a run on it; the guest is an ordinary enrolled machine with its own credential and its own socket, so that run's log, trace and artifacts are read from its run and never come back through the host.

const g = vm.ensure("lab-1");                    // on, provisioned, connected
const run = g.run("nightly", { params: { date: day } });
if (run.status !== "done") log.warn("lab-1: " + run.status + " " + (run.error || ""));
g.stop();                                        // saves its state
vm
vm.ensure(name, opts?)Built if there is none, running, holding an agent, and connected. Blocks; default 10 minutes.
vm.available(){installed, version, path, error?}. Answers rather than throwing, so one script can suit machines with a hypervisor and machines without.
vm.install({version}?)Put VirtualBox on this machine. Maintenance lane only.
vm.list()[{name, state, running, enrolled, agent_id, online}] — what VirtualBox knows joined to what the control plane knows.
vm.get(name) / vm.state(name)A handle without touching the machine, null if VirtualBox has never heard of it; and its power state.
vm.start(name)Presses the power button and returns. Already running is not an error.
vm.stop(name, {mode}?)save (default), acpi, or force.
vm.snapshot(name, snapshot) / vm.restore(name, snapshot)Restoring needs the VM stopped.

A guest

Guest handle
name / state()What VirtualBox says.
online() / agentId()What the control plane says. Only this one predicts whether a run will be picked up.
start() / stop(mode?) / waitOnline({timeout}?)
run(script, opts?)The guest's run: {id, agent_id, script, status, error, exit_code, cost_usd, queued_at, ended_at}.

run options: params, version (the active one by default), webhook, timeoutMs for the guest run's own wall clock, wait, and timeout for how long to wait here. It waits by default, because there are no promises in this runtime; { wait: false } returns the queued run instead. A guest run that failed comes back rather than throwing — branch on status, the way you would on an HTTP one.

The host's own run is billed for every second it sits waiting, and holds that machine's desk lease unless the script was deployed --parallel.

  • vm.restore rolls back the guest's enrolment too. A snapshot taken before it enrolled restores a machine with no credential, which will never connect again. Snapshot after.
  • A guest is matched to its agent by name: the VM name against the guest's Windows hostname, uppercased and truncated to 15 characters the way Windows does it. A hostname changed by hand simply will not be found.
  • ensure climbs four rungs and skips the ones already done: build the VM if there is none, switch it on, install the agent into it, wait for that agent to connect. Installing looks first and leaves an installed guest alone. It needs the fleet to allow guests and the host to hold a Windows account for that guest; where it does not, install the agent in the guest yourself and ensure starts it and waits.
  • The lifecycle calls are VirtualBox on this machine, so they work with the control plane unreachable. run, waitOnline and the waiting half of ensure do not.

Resetting a guest to a snapshot for every run is in tips & recipes.

Building a guest

When there is no VM of that name, ensure builds one: it runs Windows setup unattended from installation media, then carries on up the ladder. That takes 20 minutes to a few hours, and none of it is billed.

const g = vm.ensure("lab-2", {
  iso: "\\\\fileserver\\images\\win11.iso",
  imageIndex: 6,
});
vm.ensure options
timeoutHow long to wait for the guest's agent to connect. Default 10 minutes. vm.ensure(name, 600000) still means this.
isoA path on that machine, a UNC share, or an http(s) URL. Omitted uses the fleet's Windows installation media.
sha256The digest to check it against. Required when iso is a URL; optional and honoured for a path. Given without iso, it throws.
imageIndexWhich edition inside the ISO, counting from 1. 0 lets the media decide.
  • Media decides nothing for a guest that already exists. Building happens only when the VM is absent, so naming a different ISO does not re-image a working guest.
  • An option this call does not know is refused.
  • imageIndex: 0 takes the only edition, or the (Desktop Experience) one on a Server ISO. A client ISO carrying Home, Pro and Enterprise is refused with the editions listed. The fleet's index describes the fleet's ISO and is not applied to media a script named.
  • A URL is fetched once into a cache on that machine, verified, and reused by every later guest there — the second one costs no download. It is re-hashed before each use, not trusted for its name.
  • The VM name becomes the guest's Windows computer name, so it is at most 15 characters. Longer is refused before the build starts.
  • The guest gets a local account with a generated password, held on the host. Building it stores that account first, so an install interrupted halfway leaves a machine somebody can still sign in to.
  • That account is named after the VM: dealer-42 gets dealer42. Windows will not let a local account share the computer's name, so where the two would collide it takes a suffix — a VM called test gets test01. On the host, altengine-vm credential --name <vm> says which account a guest was built with; the password is never printed.

Installing VirtualBox

vm.install({version}?) puts the hypervisor on this machine. It returns {version, path, already_present, installed, reboot_pending, warnings}, and its time is not billed.

const r = vm.install();
if (r.already_present) log.info("VirtualBox " + r.version + " was already here");
vm.ensure("lab-2");
  • Maintenance lane only. It loads a kernel driver, so it needs the fleet's Allow maintenance-lane scripts setting and a script deployed to run in that lane. Anywhere else it refuses on the line that called it.
  • It drops the machine's network for a few seconds while the bridged-networking driver re-initialises every adapter — this run's own connection included. Log before you call it, not during.
  • A VirtualBox that was already there is left exactly as it is, and already_present says so. That is also what stops an uninstall from removing somebody else's software.
  • warnings lists anything else holding the processor's virtualization — Hyper-V, VBS, HVCI, WSL2. Not errors, but a guest can then take three hours instead of twenty minutes. They reach the run's log too.
  • A pinned version is installed by default. {version: "latest"} takes whatever Oracle published today.

user

Local accounts this machine creates for automation. A run makes one; a later run runs as it, headless, under that account's own identity. Every account is removed when the agent is uninstalled, and the agent will not touch an account it did not create. Maintenance lane only.

The machine has to be enrolled for it. These calls fail on a machine installed without --managed-account, whatever lane the script runs in. Add the flag at enrollment:

altengine-supervisor.exe --install ^
  --instance fleet ^
  --token <enrollment-token> ^
  --managed-account
const acct = user.ensure("buildbot", { admin: false });  // { name, admin, created }
job.result(user.list());
// later, dispatch a run with run_as: "buildbot" to run as it
user
user.ensure(name, {admin}?)Create the account, or rotate its password if it already exists. admin: true makes it a local administrator, otherwise a standard user. The password is kept by the agent for run_as and never returned.
user.list()[{name, admin, created}] — the accounts this agent created. Never the passwords.
user.remove(name)Delete one account this agent created.

artifact

artifact.put(name, bytes, opts?), artifact.putFile(name, path, opts?), artifact.putFolder(path, opts?). See Results are files.

What stops a run

How a run ends
BoundCatches
Cost ceilingEverything, eventually. The one bound you cannot turn off.
Stall timeoutA run doing nothing. Default 15 minutes.
Livelock checkA run repeating the same action against the same target. Any different action resets it. Tune with loopRepeats, or -1 to switch it off.
Wall clockA deployed script: only if you set one; the default is none. An ad-hoc run (code or exec): 60 seconds unless timeout_ms says otherwise, at most 10 minutes.