Docs

Forms API

Tab and Frame share a small set of form building blocks, and a script composes them to fill in forms and read pages. Each one finds its element by a target, acts the way a person would (real clicks and typing), and checks the result. For how that works inside the page, see One fill, end to end.

The SDK form methods

These are the SDK form methods: the methods the SDK gives every tab and frame for working with a page’s forms, as opposed to methods of your own. Each needs the grant shown. The read-only ones need only tabs, so a script that reads a result never needs to write:

MethodGrantDescription
fields({ within? })tabsThe form fields on the page: each with a checked target, its label, kind, value, whether it’s required, empty or invalid, its options, and limits such as maxLength.
clickables({ within?, all? })tabsThe buttons, links, tabs and menu items on the page, each with a checked target: { clickables, total }.
text(target) / attr(target, name)tabsWhat an element says, or one of its attributes. See Reading the page.
fill(target, value, { sensitive? })formsSets a field and checks it took: see What a fill does.
clear(target)formsEmpties a field, deselects its options, or removes its files.
upload(target, path)formsAttaches a file (relative to the script; needs read= for it).
click(target) / press(key)formsEverything that isn’t a field value: buttons, links, "Escape", "Tab".

A script fills what it knows and checks what is left:

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

const profile = JSON.parse(files.read("profile.json"));
const tab = await tabs.find(/jobs.example.com/.+/apply/);
await tab.fill({ label: "Name" }, profile.name);
await tab.fill({ label: "Email" }, profile.email);
await tab.fill({ label: "Location" }, profile.location);
await tab.fill({ label: "Are you willing to relocate?" }, false);

const left = (await tab.fields()).filter((f) => f.required && f.empty).map((f) => f.label);
await context.result({ stillRequired: left });

There is no SDK form method that fills a whole form: the script decides what happens when a field fails and what counts as finished. A failed fill throws a FieldError and stops the script, which is usually right; to carry on past one field, catch it for that call.

Targets

A target names one element the way a person would find it. The easiest way to get one is tab.fields() (or tab.clickables() for buttons and links), which lists each with a target already checked against the live page. The forms a target can take, in the order to prefer them:

TargetFindsExample
{ label }A field by its label, or by the question text above it.{ label: "Email" }, { label: /^e-?mail/i }
{ role, name }An element by its ARIA role and accessible name.{ role: "button", name: "Next" }
{ text }An element by its visible text, for things with no role or label. Text that belongs to a <label> means that label’s field.{ text: "Accept all" }
CSS stringAnything a selector can reach. A last resort: it breaks when the site’s markup changes."#first_name", "input[name=email]"
field.refA field from fields(), by reference. Valid only until the page changes.(await tab.fields())[0].ref

Every form except a ref accepts within, another target that narrows the search: { label: "Phone", within: "#work" }, or { role: "button", name: "Upload File", within: { label: "Resume" } }.

The rules every target follows:

  • It must match exactly one visible element. None is not_found and more than one is ambiguous, both with suggestions. Hidden elements don’t count, except file inputs, which sites hide behind a styled button. (waitFor is the one exception: it acts on nothing, so any visible match will do.)
  • Matching is exact, but ignores case, accents, extra spaces, and a trailing *, : or “(optional)“. A RegExp covers wording variants and other languages.
  • Names follow the W3C rules browsers and screen readers use, so an icon button is named by its image’s alt text, and a field by its <label> or aria-label.
  • fields() hands out only checked targets, adding within where a label repeats, and lists sturdier alternates (stable ids, name attributes, test hooks) for labels likely to change.

The brittle-css-target lint rule flags CSS targets likely to break on a site’s next build: positions (:nth-child), generated ids and class names, and long chains.

What a fill does

fill works out what kind of field it has and sets it the way that kind expects. The value’s type follows the field:

FieldValueExample
Text, email, date, text areastringfill({ label: "Email" }, "ada@example.com")
Select, combobox, autocompletean option’s textfill({ label: "Country" }, "Portugal")
Multi-select, checkbox groupstring[]fill({ label: "Skills" }, ["TypeScript", "Go"])
Single checkboxbooleanfill({ label: "I agree to the terms" }, true)
Radio group, Yes/No buttonsan option’s text, or true/false for Yes/Nofill({ label: "Willing to relocate?" }, false)
Filea path (or use upload)upload({ label: "Resume" }, "resume.pdf")

How it behaves:

  • It types and clicks like a person, with trusted input, then leaves the field so the page’s own validation runs, and reads the value back. A value the page did not keep is a not_committed error, not a silent success.
  • It is idempotent. A field already holding the value is left alone, and an input already holding the same file isn’t uploaded to again, so running a script twice on a filled form changes nothing.
  • Secrets stay out of results and logs. A password, card number or one-time-code field is treated as sensitive automatically; pass { sensitive: true } for any other secret. Listings (fields) withhold such values, and the literal-secret lint rule flags a secret written into the script.

Errors

Every form failure is a FieldError with a code, a message that names the closest candidates and one next step, and candidates or options as data. The column “Changed the page” says whether the failed call left anything behind: running a script again is safe when its only writes are fill and upload, not after it clicked Next or submitted.

CodeMeaningChanged the pageWhat to do
not_foundnothing matchesnouse a target from fields(); the message suggests the closest
ambiguousmore than one matchesnoadd within, or match more exactly
stale_refthe page re-rendered or navigatednocall fields() again
coveredsomething is on top of the click pointnodismiss it first
not_editabledisabled or read-onlynowait for the page, or skip the field
no_optionno option matches the valueno (a menu may be open)use one of the options the message lists
not_committedthe page did not keep the valueyes, partlyread the message: truncated, reformatted, or changed
timeoutit never appeared or never became visiblenowait for the page, or check the tab
not_permittedanother site, or a submission without submitno (a submission was stopped)change the header only if the task needs it

A not_committed, timeout or not_editable whose message names a custom parts map means the form keeps that part of the field (its shown value, what opens it, its clear button) where web standards don’t say. Custom Parts explains how to tell fill, clear and fields() where.

To carry on past one field, catch its error and decide:

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

const tab = await tabs.active();
try {
  await tab.fill({ label: "Middle name" }, "Augusta");
} catch (e) {
  if (!(e instanceof FieldError && e.code === "not_found")) throw e;
  console.log("No middle name field on this form; skipping it.");
}

Submitting

A script submits a form only when its task says to. It declares submit, and asks first:

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

const tab = await tabs.active();
if (await ui.confirm("Submit the application to Example Co?")) {
  await tab.click({ role: "button", name: "Submit application" });
}

Without submit, the SDK stops any submission a click or Enter causes and throws not_permitted, and refuses a click on a button named like Submit, Send, Pay or Place order. A submission a page makes some other way (a script that posts when a plain <div> is clicked) is not caught by the guard; the name rule is the backstop, and the submit-unconfirmed lint rule flags a script that may submit but never asks.

Reading the page

text(target) returns the visible text of the element a target names, and attr(target, name) one of its attributes. They need only tabs, so a script that reads a result never needs page-js. Hand what it found back with context.result:

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

const tab = await tabs.active();
const status = await tab.text({ role: "status" });
const receipt = await tab.attr({ role: "link", name: "Download receipt" }, "href");
await context.result({ status, receipt });

Frames

A form inside an iframe is reached with tab.frame(urlOrRegExp), which returns a Frame with the same SDK form methods as a tab, or null when no frame matches. sites= checks the frame’s own site:

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

const tab = await tabs.active();
const frame = await tab.frame(/forms.example-widget.com/);
if (frame) await frame.fill({ label: "Email" }, "ada@example.com");