Docs

SDK Overview

The tranquil/automation SDK is what an automation script imports to drive Tranquil’s browser, fill in forms, read pages and talk to the app. This page shows how it works: where each part of your code runs, and what happens between tab.fill(…) in your script and a value landing in a web page. The other SDK pages are the reference:

PageCovers
Tranquil SkillThe skill Tranquil ships for LLMs that write automations: where it lives and what it makes them do
PermissionsThe @permissions header every script declares, and what each grant allows
Tranquil APItabs, ui, files, context and the rest
Forms APIfields, fill, click and the other SDK form methods, targets and error codes
Page Code APIYour own code inside a page with evaluate, and when to use it instead of a map
Custom PartsOptional maps for forms not built to web standards, and the commands that draft and check them
TestingChecking targets and parts on the live page, and offline against saved HTML
CommandsEvery SDK command in the command palette, and its key

For the steps to have an LLM agent write a script, see Automate with the Tranquil Skill.

One import, no build setup

An automation is a TypeScript file that runs as its own Deno program. It declares what it may do in a comment header and gets everything else from one import:

// Tranquil automation. Rules for writing and changing it: ~/.tranquil/llm/writing-automations.md
// @permissions forms sites=example.com
import { context, tabs, ui } from "tranquil/automation";

const tab = await tabs.active();
await tab.fill({ label: "Email" }, "ada@example.com");
await ui.notify("Filled.");
await context.result({ done: true });
  • The first line points any LLM agent that opens the script at the rules for writing one.
  • The @permissions line is required; a script without one is refused (see Permissions).
  • There are no magic globals. Everything comes from the import.

Every automation runs with Tranquil’s config, ~/.tranquil/automations/deno.json, never one near the script. That config gives these modules short names, so a script imports them as import { tabs } from "tranquil/automation" rather than by a full URL:

  • tranquil/automation, the SDK, and tranquil/automation/testing, its offline test helpers;
  • the Deno standard library modules @std/assert, @std/async, @std/collections, @std/csv, @std/encoding, @std/fmt, @std/fs, @std/http, @std/path, @std/testing and @std/text (subpaths work too: @std/async/delay);
  • capnweb.

Tranquil pins their versions and updates them with each release. Import anything else by its full specifier, such as jsr:@std/dotenv@^0.225 or npm:zod@3, so the file still says everything it needs.

A script can also import .ts modules you write yourself, by relative path, like any TypeScript: shared helpers, or a custom parts map several scripts use. Declare each one in read= (read=lib/parts.ts), as you would any file the script reads; the runner’s sandbox refuses an undeclared one. How you organize shared code is up to you.

Editing outside Tranquil. An editor with its own Deno support (VS Code’s Deno extension, for example) doesn’t know about Tranquil’s config. Point its deno.config setting at ~/.tranquil/automations/deno.json so tranquil/automation and the short names resolve.

Where your code runs

A script is a separate process, sandboxed by Deno: not the web page, and not the app. It reaches everything else through two connections, one to the Tranquil window that started it and one to the browser:

Browser tabhost bridgeownsform runtime callspage codeinput eventsYour scripta Deno subprocess, sandboxedTranquil windowtabs · ui · clipboard · workspace · config · run logIsolated worldthe SDK's form runtimeMain worldthe page's scripts and evaluate()Trusted inputtyping, clicks, keysthe page's DOM

The script uses four paths, each for a different kind of work. Reach for this table when something behaves unexpectedly and you need to know which side of the boundary it happened on:

PathUsed forWhat crossesGrant
Host bridgeAsking the app: find or open tabs, notify, confirm, clipboard, per-script config, the run logRPC calls and their resultsdepends on the call (tabs, clipboard; ui and config need none)
Form runtimeFinding fields and buttons, reading values, planning a fillthe method name and JSON arguments in; JSON results outtabs to read, forms to write
Trusted inputTyping, clicking, pressing keysinput events, marked trusted by the browserforms
Page codeYour own function, run in the page with tab.evaluateyour function’s source and JSON arguments in; its JSON result outpage-js
  • The host bridge is RPC (Cap’n Web) over a WebSocket bound to 127.0.0.1, opened with a token issued for this run. Only the capabilities the header granted are there to call.
  • The DevTools protocol (CDP) is the browser’s own automation interface. The script connects to the tab it was handed and sends it calls and input events.

The form runtime

The SDK form methods need to look at the page: which fields exist, how each is labelled, what it holds. That work runs inside the tab, in a small runtime the SDK ships to the page. Every form call goes through one function, sent with each call:

function (hash, name, args) {
  if (!globalThis.__tqForms || globalThis.__tqFormsHash !== hash) {
    globalThis.__tqForms = (RUNTIME_SOURCE)(globalThis);
    globalThis.__tqFormsHash = hash;
  }
  return globalThis.__tqForms[name](...args);
}

What it does, in order:

  1. Builds the runtime once per document. The first call on a page, or the first after the page navigated, creates it from its source and keeps it on the global object. Later calls reuse it.
  2. Rebuilds it when the SDK changed. The runtime is kept with a hash of the source it was built from. A page still holding one from another build of the SDK (after an update) gets a fresh one.
  3. Calls the one method asked for (resolve, prepareText, commit, …) and returns its result.

Four things follow from how it is sent:

  • It runs in an isolated world, not the page’s own JavaScript context (see Two worlds, one page).
  • Only JSON crosses. An element can’t be sent back to the script, so the runtime keeps the elements it found and hands out short reference strings (ref) that later calls pass back. That is why it persists between calls.
  • The code is only ever the SDK’s own source. Values such as a target or the text to type go as arguments, never spliced into the code, so a quote in a value can’t change what runs.
  • The whole source is sent with every call, though the page builds the runtime only once. ADR-0028 moves this into the app, which will inject the runtime itself.

One fill, end to end

await tab.fill({ label: "Email" }, "ada@example.com") on a text field. The SDK finds the field, types into it as a person would, then leaves it so the page’s own validation runs, and reads back what the page kept:

check the forms grantread the page's address (sites= check)resolve the target: label "Email"the field, with a refprepareText(ref): focus the field, select its textgive the tab keyboard focusInput.insertText "ada@example.com" (trusted)read(ref)the value the field holdscommit(ref): leave the field, let validation runthe field after validation (value, invalid, error)log the action: fill, target, site, okYour scriptForm runtime (isolated world)PageTranquil window

The steps that matter:

  • The checks come first. The grant and the page’s site are checked before anything touches the page; a refusal is logged like any other outcome.
  • The typing is real input. It arrives through the browser’s input pipeline, so the page sees trusted events, with a real focus and blur, the same as from a keyboard. Values set by synthetic events from page JavaScript were often not accepted by a site’s validation, which is why the runtime finds and plans but never types.
  • The result is read back. A value the page didn’t keep (truncated, reformatted, rejected) is a not_committed error, not a silent success.
  • The log never has the value. The action log records the SDK method, target, site and outcome only.

SDK: Check Custom Parts does the checks and the resolve, checks the parts a dropdown needs, then logs the write it would have made instead of typing (see Check Custom Parts).

Two worlds, one page

A browser tab can run JavaScript in more than one context. Each has its own global variables, but all of them see the same page:

  • The main world is the page’s own. Its scripts, and your function in tab.evaluate, run here.
  • The isolated world is the SDK’s, created for the tab and named tranquil-sdk (that name shows in DevTools’ context picker). The form runtime lives here, and so does the only use of a script’s custom parts map: its selectors are looked up here, never run.

Both read and change the same DOM, but the page’s scripts can’t see __tqForms, call it, or replace the built-ins it uses. A site that has tampered with Array.prototype or overridden Element.prototype methods changes nothing about how the SDK finds a field.

What doesn’t go through the runtime

The form runtime is one of the four paths, not the whole SDK:

  • Typing, clicking and keys are trusted input sent straight over the DevTools protocol.
  • tab.evaluate(fn) runs your function in the page’s main world, with the page’s own globals, and needs page-js. See Page Code API.
  • Everything about the app itself (opening a tab, a notification, the clipboard, config, the run log) goes over the host bridge.

The guard rails in this flow

You approve what a script may declare; the SDK holds the script to it at these points:

CheckWhere it runsWhat it stops
The grantin the SDK, before each callan SDK method the header did not grant (fill without forms)
sites=in the SDK, before each page actionacting on any site but the declared ones
The submit guardin the page, armed around click and press; and in the SDK, by button namea click or Enter that would submit a form, or a click on a button named like Submit, Send or Pay, without submit or without a yes to the run’s most recent ui.confirm
The action login the app, after each page actionnothing; it records what was done, so you can see it

These checks bind scripts that use the SDK. A script granted any browser key can also reach the browser’s debugging port directly and bypass them, until ADR-0028 closes that port and moves the DevTools connection into the app.