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/sdkprograms 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.jsonin any parent directory takes precedence, andtranquil/automationwon’t resolve. Resolution walks up from the script’s own directory looking for adeno.jsonordeno.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’sdeno.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-Rwith the.tseditor 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’simportlines 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
.tsfile). - A saved palette command — see Save it as a command.
- A URL trigger — configured to re-run on matching page loads (see context).

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.

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:
| Key | Grants |
|---|---|
none | Nothing at all — no files, no browser, no network. |
browser | Driving tabs — opening any page and running code in it, as you, inside your signed-in sessions. |
clipboard | Reading 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=path | File access, by exact path. Relative paths resolve against the script’s folder (write=output.md, write=results). A bare . is rejected. |
env=NAME | Reading those environment variables. |
import=host | Importing 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:
| Method | Returns | Description |
|---|---|---|
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:
| Member | Returns | Description |
|---|---|---|
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.url | Promise<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.
| Method | Description |
|---|---|
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).
| Method | Returns | Description |
|---|---|---|
files.write(name, content) | string | Writes the file; returns its absolute path (feeds ui.open). |
files.read(name) | string | Reads a file as UTF-8. |
Four rules govern both:
filesworks on disk undercontext.scriptDir, sofiles.readsees the saved file — not an unsaved editor buffer.- Permissions still apply:
files.read("input.txt")needsread=input.txt, andfiles.write("out.md")needswrite=out.md. A script’s own folder is not granted for free. - You name the file, not the folder.
files.writewon’t create missing parent directories. For a subfolder, callDeno.mkdirSync(dir, { recursive: true })first — the SDK has nomkdirof its own.
context
Read-only facts about the current run — its identity, its sandbox, why it started, and how it stops:
| Field | Description |
|---|---|
context.runId | This run’s unique id. |
context.scriptDir | The script’s directory (its file sandbox). |
context.trigger | How the run started: { kind: "manual" }, { kind: "command", name }, or { kind: "url", url }. |
context.signal | An 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:
| Method | Description |
|---|---|
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 panelThe clipboard —
await 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 page — count-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 page — reader-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 file — page-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.ts → Automations: 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.