Docs

Writing Automations

Write a TypeScript file, press Cmd-Shift-R, and it drives the browser tab in front of you — scraping the page, restyling it, walking a list of URLs, writing results to a file next to the script. This page is the reference for doing that: the tranquil/automation SDK you import, the permissions you declare, and the patterns worth reaching for. Everything here runs inside the Tranquil desktop app, against a live page. If you haven’t run an automation yet, start with Running Automations and Your First Automation; this page goes deeper.

Browser automations vs. workflows. This page is about browser automations — scripts you run inside the desktop app against a live page. Tranquil also has workflows: @tranquil/sdk programs that run headless on a schedule, webhook, or event. Those run on the Engine and are a separate topic (and not yet available).

Dev preview — not stable. Tranquil is in early, active development. Everything here — APIs, interfaces, and behavior — is provisional and will change before a stable release, often without notice or backward compatibility. Don’t build anything you depend on against it.

Where automations live

An automation is just a .ts file — there’s no required folder, manifest, or naming convention. Put it anywhere in your project and run it (see Running a script below). Files resolve relative to their own directory, so a script and the files it reads or writes travel together. There’s no install step and no project scaffolding, because Tranquil maintains a Deno import map at ~/.tranquil/automations/deno.json that maps tranquil/automation to the SDK, and any .ts on your machine picks it up.

A deno.json in any parent directory takes precedence, and tranquil/automation won’t resolve. Resolution walks up from the script’s own directory looking for a deno.json or deno.jsonc, and falls back to Tranquil’s only if it finds none — so if you keep automations inside an existing Deno project, add the mapping to that project’s deno.json. Copy the value from Tranquil’s copy. It’s an absolute path to the installed SDK, so it differs per machine and shouldn’t be committed to a shared repo.

How a script runs

Automations run in a sandboxed Deno subprocess — a separate process from the editor, not the web page and not the app’s own context. Two consequences follow from that:

  • The sandbox is default-deny, with nothing granted implicitly. Files — including the ones next to the script — the browser, the clipboard, network access, and running other programs are all denied unless the script declares what it needs (see Permissions). A runaway script can’t touch the rest of your machine, and it can’t hang the editor — it’s killable.

  • The SDK is imported, not injected. There are no magic globals. Everything comes from one import:

    import { tabs, ui, files, clipboard, config, workspace, context } from "tranquil/automation";

To touch the page itself — its document and window — you pass a function to tab.evaluate(). That function is serialized and runs inside the page; everything outside it runs in your script:

// @permissions browser
import { tabs, ui } from "tranquil/automation";

const tab = await tabs.active();               // in the script
const title = await tab.evaluate(() => {       // this callback runs IN THE PAGE
  return document.title;
});
await ui.notify(title);                        // back in the script

Scripts are plain Deno TypeScript, so you can also import from JSR and npm (import { delay } from "jsr:@std/async"), use await/await using at the top level, and run deno check / deno fmt on them.

Running a script

Four ways, all equivalent — each runs the file (or the selection) as one automation:

  • Cmd-Shift-R with the .ts editor focused (not the browser tab). Runs the whole file, or — if you have text selected — just the selection, which makes it a quick REPL. The file’s import lines and its permissions header come along automatically, so a single statement runs on its own. Save before you run — a run executes the file on disk, so an unsaved edit runs the previous version.
  • The ▶ Run button on the editor’s tab bar (shown for any .ts file).
  • A saved palette command — see Save it as a command.
  • A URL trigger — configured to re-run on matching page loads (see context).

The ▶ Run button at the right of a .ts file's editor tab bar

Every run — its state, duration, and output — appears in the Automation Runs panel (bottom dock; open it with “Automations: Toggle Runs Panel”). A running script can be cancelled there, and a failed one links to its output with a real file:line stack trace. console.log from the script body lands there too; logs inside tab.evaluate(() => { … }) run in the page, so they surface in that tab’s DevTools instead. To stop on a line and inspect variables, see Debugging Automations.

The Automation Runs panel: a list of runs with state dots and durations on the left, the selected run's output on the right

Permissions and timeouts

Every automation must declare its permissions. A script with no @permissions line is refused rather than run on a silent baseline — “declares nothing” and “needs nothing” shouldn’t look the same. That’s why every runnable example on this page opens with one. The declaration goes in a comment header at the top of the file, above the imports:

// @permissions browser net=api.github.com
// @timeout 15m

A script that touches nothing outside itself says so explicitly:

// @permissions none

The grants you can declare, space-separated on that one line:

KeyGrants
noneNothing at all — no files, no browser, no network.
browserDriving tabs — opening any page and running code in it, as you, inside your signed-in sessions.
clipboardReading and writing the system clipboard.
net=host[,host]Network access to those hosts.
run=cmd[,cmd]Running those commands — effectively full user privilege, so grant deliberately.
read=path / write=pathFile access, by exact path. Relative paths resolve against the script’s folder (write=output.md, write=results). A bare . is rejected.
env=NAMEReading those environment variables.
import=hostImporting modules from hosts beyond the defaults.

Grants are paths, not a blanket. Deno’s path permissions are recursive, so a directory grant covers everything beneath it — including folders that do not exist yet. A bare . is therefore rejected: it would hand the script its whole folder, which is the implicit grant this model removed, only written down. Name the file you write, or a subfolder you own.

@timeout bounds the run (90s, 15m, 2h, or none for watcher-style scripts); the default is 10 minutes.

Approval

The first time you run a script that asks for anything, Tranquil shows you what it declared and waits for you to approve it. The approval is remembered per script, so you’re asked once, not on every run — and editing the header re-prompts, so a script can’t quietly widen what it may do after you’ve approved it. A script declaring none never prompts.

Declining isn’t a failure. The run is recorded as cancelled in the Automation Runs panel, and a refused run — a missing header, a bad one — is recorded there too, with the reason, so the message is still there after the notification goes away.

The automation SDK

Seven surfaces, all from one module. Import the ones your script uses:

import { tabs, ui, files, clipboard, config, workspace, context } from "tranquil/automation";

There’s no install step — tranquil/automation resolves through the import map described in Where automations live. The rest of this section is what each surface gives you.

tabs

tabs resolves browser tabs; each returns a Tab handle. A handle is bound to a stable tab, so it survives navigation — tab.url is a live read, not a snapshot. Five ways to get one, differing only in how the tab is located:

MethodReturnsDescription
tabs.active()Promise<Tab>The tab you’re looking at (or most recently were). Opens one on the start page if no tab is open.
tabs.find(urlOrRegExp)Promise<Tab>An open tab matching an exact URL string or a RegExp.
tabs.all()Promise<Tab[]>Every open http(s) tab.
tabs.open(url, { background?, location?, activate?, hideURLBar? })Promise<Tab>Opens a tab. background: true opens it off-screen (for scraping/auth), capped at ~4 concurrent, inheriting this window’s login session. The rest decide where it lands, whether it takes focus, and whether it looks like a browser — see below.
tabs.triggered()Promise<Tab>In a URL-triggered run, the tab whose load fired the trigger.

Everything you can do with a handle once you have one — read the page, wait on it, navigate it, and change how the tab itself is presented:

MemberReturnsDescription
tab.evaluate(fn, ...args)Promise<T>Runs fn in the page with JSON-serializable args; resolves its return value. Also accepts an expression string.
tab.waitFor(selector \| fn, opts?)Promise<void>Polls until a CSS selector matches, or a page predicate returns truthy.
tab.goto(url, { waitUntil? })Promise<void>Navigates the tab. waitUntil is "load" (default) or "domcontentloaded".
tab.waitForNavigation(opts?)Promise<void>Awaits a navigation something else triggered (a click, a redirect).
tab.title()Promise<string>The page title.
tab.urlPromise<string>The current URL (a live read).
tab.screenshot()Promise<Uint8Array>A PNG of the tab.
tab.toggleURLBar(hidden?)Promise<boolean>Hides or shows the tab’s address bar; flips it when called with no argument. Resolves to the new state.
tab.close()Promise<void>Closes the tab.

Tabs are AsyncDisposable, so await using tab = await tabs.open(url) closes the tab automatically when the block ends — no leaked tabs in loops.

A tab opens in the center, takes focus, and shows an address bar unless you say otherwise. location accepts "center", "right" or "bottom" — a dock location opens the dock as well as the tab — activate: false opens without taking focus, which is what puts a panel beside your work instead of in front of it, and hideURLBar drops the address bar for a page presented as a panel rather than as a browser (it’s remembered across a window reload):

await tabs.open(url, { location: "right", activate: false, hideURLBar: true });

For a tab that’s already open, tab.toggleURLBar() does the same thing live — hiding the strip with the URL field and the nav buttons, not the tab itself.

Name the location on every open in a script that uses a dock, including the ones you want in the center. Tranquil remembers where a URL was last opened and reuses that the next time the same URL is opened — so a script that puts one page in a dock can find its later tabs going there too. Passing location explicitly overrides what was remembered.

tab.evaluate compiles your function through the browser’s debugger, so it runs even on sites with a strict Content-Security-Policy (it isn’t subject to the page’s script-src) — which is what makes scraping locked-down pages work.

ui

Two ways to reach the person running the script: tell them something, or put a file in front of them.

MethodDescription
ui.notify(message, { level? })Tells the user something: shows a notification and writes the message to the run output. level is "info" (default), "success", "warning", or "error".
ui.open(path, { split? })Opens a file in the editor. split is "left" \| "right" \| "up" \| "down".

files

Reading and writing files that sit next to the script. Paths resolve against the script’s own directory (context.scriptDir).

MethodReturnsDescription
files.write(name, content)stringWrites the file; returns its absolute path (feeds ui.open).
files.read(name)stringReads a file as UTF-8.

Four rules govern both:

  • files works on disk under context.scriptDir, so files.read sees the saved file — not an unsaved editor buffer.
  • Permissions still apply: files.read("input.txt") needs read=input.txt, and files.write("out.md") needs write=out.md. A script’s own folder is not granted for free.
  • You name the file, not the folder.
  • files.write won’t create missing parent directories. For a subfolder, call Deno.mkdirSync(dir, { recursive: true }) first — the SDK has no mkdir of its own.

context

Read-only facts about the current run — its identity, its sandbox, why it started, and how it stops:

FieldDescription
context.runIdThis run’s unique id.
context.scriptDirThe script’s directory (its file sandbox).
context.triggerHow the run started: { kind: "manual" }, { kind: "command", name }, or { kind: "url", url }.
context.signalAn AbortSignal that fires when the run is cancelled — pass it to fetch, timers, etc.

Everything else

Three smaller surfaces — the app clipboard, the open project root, and state that outlives a run:

MethodDescription
clipboard.read() / clipboard.write(text)The app clipboard (distinct from the page clipboard inside evaluate).
workspace.projectDir()Absolute path of the first open project root, or "".
config.get(key) / config.set(key, value)Per-script persistent state that survives runs and restarts. Stored under the script’s own namespace — you can’t read or write arbitrary app config.

To run a shell command, use Deno directly under a declared run= grant (see Permissions):

// @permissions run=git
const out = await new Deno.Command("git", { args: ["rev-parse", "HEAD"] }).output();
console.log(new TextDecoder().decode(out.stdout).trim());

Getting input into a run

There’s no interactive prompt — window.prompt() isn’t supported in the page, and the script has no terminal to read from. Parameterize a run through one of these instead:

  • A config file next to the script — the most flexible. Keep inputs in a sibling file and read them with files.read. A .env-style file (so it can carry several inputs) parses cleanly with @std/dotenv:

    // @permissions read=input.txt
    import { files, ui } from "tranquil/automation";
    import { parse } from "jsr:@std/dotenv/parse";
    
    // input.txt, saved next to the script:
    //   query=business automation
    //   count=10
    const input = parse(files.read("input.txt"));
    await ui.notify(`Searching for "${input.query}"…`);   // pops a notification AND logs to the Runs panel
  • The clipboardawait clipboard.read() for a quick one-off value.

  • The trigger — a URL-triggered run gets its originating page from context.trigger / tabs.triggered().

Write results back out with files.write — pair it with ui.open to reveal the file in the editor (await ui.open(files.write("output.md", md))), which reloads it in place if it’s already open.

Example catalog

Common shapes to adapt, from a single tab to several at once. (These are also in the examples repo, if you’d rather open and run them — but nothing here depends on that.)

One tab

Read the pagecount-elements.ts:

// @permissions browser
import { tabs, ui } from "tranquil/automation";

const tab = await tabs.active();
const count = await tab.evaluate(() => document.querySelectorAll("img").length);
await ui.notify(`This page has ${count} image(s).`);

Restyle the pagereader-mode.ts hides clutter and widens the main column (highlight-links.ts is the same shape — it outlines every external link):

// @permissions browser
import { tabs } from "tranquil/automation";

const tab = await tabs.active();
await tab.evaluate(() => {
  document
    .querySelectorAll<HTMLElement>('header, footer, aside, nav, [role="banner"]')
    .forEach((el) => (el.style.display = "none"));
  const main = document.querySelector<HTMLElement>("main, article") || document.body;
  main.style.maxWidth = "720px";
  main.style.margin = "0 auto";
  main.style.fontSize = "18px";
});

Scrape to a filepage-info.ts reads stats, writes them, and opens the result (files.write + ui.open):

// @permissions browser write=page-info.txt
import { files, tabs, ui } from "tranquil/automation";

const tab = await tabs.active();
const stats = await tab.evaluate(() => {
  const links = document.querySelectorAll("a").length;
  const images = document.querySelectorAll("img").length;
  return `${document.title}\n\nLinks: ${links}  |  Images: ${images}`;
});
await ui.open(files.write("page-info.txt", stats));

Several tabs

Sequential crawl — reuse one visible tab, navigating between URLs:

// @permissions browser
import { tabs } from "tranquil/automation";

const tab = await tabs.active();
for (const url of ["https://example.com/a", "https://example.com/b"]) {
  await tab.goto(url);
  console.log(await tab.title());
}

Fan-out — visit several URLs in background tabs, a few at a time, and collect the results. pooledMap bounds concurrency; await using auto-closes each tab:

// @permissions browser write=titles.tsv
import { pooledMap } from "jsr:@std/async@1/pool";
import { files, tabs, ui } from "tranquil/automation";

const urls = ["https://example.com/", "https://example.org/", "https://example.net/"];

const rows = pooledMap(3, urls, async (url) => {
  await using tab = await tabs.open(url, { background: true });
  await tab.waitFor("h1");
  return `${await tab.title()}\t${url}`;
});

await ui.open(files.write("titles.tsv", (await Array.fromAsync(rows)).join("\n")));

Save it as a command

Once a script is useful, run “Automations: Register Current File” from the command palette (Cmd-Shift-P) with it focused. It becomes a permanent palette command named automatically from the filename — count-paragraphs.tsAutomations: Count Paragraphs — that targets the active tab and persists across restarts, no longer needing the file open. See Your First Automation for the walkthrough.


Next: Debugging Automations — stop it on a line and look around.