← 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.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.

An office PC at 2am is locked, so a script with a headless half and a desktop half asks sys.desktop() rather than finding out from a failure — provoking one costs the run's whole desktop timeout to learn something you could have asked for free.

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 what .NET's Rfc2898DeriveBytes defaults to, so it is what most applications you have to match were built against.

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. A separate function rather than an option on fetch, because nothing can look at a string and tell a path from the contents of a file.

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 that tomorrow's run treats as the export.
  • 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 because the server is often the only thing that knows what the file is: 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 — one unreachable host out of four thousand must not discard the files that did arrive. The bulk pattern is in tips & recipes.

db

SQL Server, PostgreSQL and MySQL. The application on the screen keeps its data somewhere, and that somewhere is usually reachable from the same PC.

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 — so an example written for your database works as it stands. 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. A report on these systems legitimately takes minutes, so 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, env for reading, and job.params / outDir / emit / state / worker.
Refused, with the reasonui, mouse, clip, vision, browser, vm, the rest of sys, job.next, job.waitForData, env.set, and a task inside a task.

There is one desktop and one machine, so nothing that drives either is available on a second thread. The rest are decisions about the run rather than about one item: make them in the main script, with what the task returned.

failFast is off by default — one bad account out of four thousand should not discard the rest. 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();

One function rather than a matcher and a handler, because interruptions arrive in sequences — dismiss the error, confirm the overwrite, answer "keep both files" — and each step wants to see what the last one left behind. Returning true also resets the enclosing wait's deadline: a script that asked for fifteen seconds meant fifteen seconds of waiting for its dialog, not fifteen minus the time spent clearing three modals out of the way.

A window

Window methods
focus() / isForeground()focus() waits and confirms.
title() / rect() / pid / processtitle() re-reads now.
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, and the asymmetry is deliberate: find is looking for one thing that must be there, while findAll is also how a script asks is there anything? — and making that take thirty seconds to answer "no" would be worse than the trap it fixes. The trap is real though: on a screen that fills in — a grid populated after the query returns, which is every one of them — 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.

clip

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

vision

Covered on the Automation page. The surface:

vision
vision.text({region}?)Every word, with a rectangle and a confidence.
vision.find(phrase, opts?) / findAllnull when absent. {region, exact}.
vision.waitForText(phrase, opts?)Throws, and says what was readable.
vision.findImage(template, opts?)An ARRAY of matches, newest-best first, up to max (default 16) — [] when absent, so test .length. {path} or {base64}; {threshold, region, max}.
vision.bestImageMatch(template, opts?)The single best match, or null.
vision.waitForStill(opts?)Returns nothing — it waits for the pixels to settle. {stillFor, tolerance}.
vision.waitForChange(opts?)The rectangle that moved: {x, y, w, h}. {minChange, tolerance}.

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.

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 matches the option's visible TEXT, not its value. 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. browser.open owns what it started; this is a guest in somebody else's process, so 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. Driving whichever one came first is a wrong answer that looks like a right one. A splash window, a second document or a hidden helper page is ordinary, so the refusal names the pages — and match tests the url as well as the title, which is what 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. Measured: 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. A misspelled iso quietly falling back to the fleet's would install the wrong Windows and say nothing about it for an hour.
  • 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 — which one you are licensed for is not a technical question. 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 hour is spent, not after: Windows would truncate it and the guest would come online under a name its host does not match.
  • 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 — the rung below owning guests at all. 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: the install was still right, but a guest that takes three hours instead of twenty minutes looks exactly like a hang unless somebody was told. 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 — putting a standing account on somebody's PC is the administrator's decision, made at the PC. 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. Anything different resets it, including a log line. Tune with loopRepeats, or -1 to switch it off.
Wall clockOnly if you set one. The default is none.