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:
| Page | Covers |
|---|---|
| Tranquil Skill | The skill Tranquil ships for LLMs that write automations: where it lives and what it makes them do |
| Permissions | The @permissions header every script declares, and what each grant allows |
| Tranquil API | tabs, ui, files, context and the rest |
| Forms API | fields, fill, click and the other SDK form methods, targets and error codes |
| Page Code API | Your own code inside a page with evaluate, and when to use it instead of a map |
| Custom Parts | Optional maps for forms not built to web standards, and the commands that draft and check them |
| Testing | Checking targets and parts on the live page, and offline against saved HTML |
| Commands | Every 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
@permissionsline 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, andtranquil/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/testingand@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.configsetting at~/.tranquil/automations/deno.jsonsotranquil/automationand 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:
flowchart TD
S["Your script<br/>a Deno subprocess, sandboxed"]
H["Tranquil window<br/>tabs · ui · clipboard · workspace · config · run log"]
subgraph TAB["Browser tab"]
W["Isolated world<br/>the SDK's form runtime"]
M["Main world<br/>the page's scripts and evaluate()"]
I["Trusted input<br/>typing, clicks, keys"]
D[("the page's DOM")]
end
S -->|"host bridge"| H
S -->|"form runtime calls"| W
S -->|"page code"| M
S -->|"input events"| I
H -.->|"owns"| TAB
W --> D
M --> D
I --> DThe 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:
| Path | Used for | What crosses | Grant |
|---|---|---|---|
| Host bridge | Asking the app: find or open tabs, notify, confirm, clipboard, per-script config, the run log | RPC calls and their results | depends on the call (tabs, clipboard; ui and config need none) |
| Form runtime | Finding fields and buttons, reading values, planning a fill | the method name and JSON arguments in; JSON results out | tabs to read, forms to write |
| Trusted input | Typing, clicking, pressing keys | input events, marked trusted by the browser | forms |
| Page code | Your own function, run in the page with tab.evaluate | your function’s source and JSON arguments in; its JSON result out | page-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:
- 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.
- 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.
- 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:
sequenceDiagram
participant S as Your script
participant R as Form runtime (isolated world)
participant P as Page
participant H as Tranquil window
S->>S: check the forms grant
S->>P: read the page's address (sites= check)
S->>R: resolve the target: label "Email"
R-->>S: the field, with a ref
S->>R: prepareText(ref): focus the field, select its text
S->>H: give the tab keyboard focus
S->>P: Input.insertText "ada@example.com" (trusted)
S->>R: read(ref)
R-->>S: the value the field holds
S->>R: commit(ref): leave the field, let validation run
R-->>S: the field after validation (value, invalid, error)
S->>H: log the action: fill, target, site, okThe 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_committederror, 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 needspage-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:
| Check | Where it runs | What it stops |
|---|---|---|
| The grant | in the SDK, before each call | an SDK method the header did not grant (fill without forms) |
sites= | in the SDK, before each page action | acting on any site but the declared ones |
| The submit guard | in the page, armed around click and press; and in the SDK, by button name | a 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 log | in the app, after each page action | nothing; 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.