ADR-0026: Automations fill forms through a small, stable SDK on trusted input
Status: Active · Date: 2026-10-01 · Implemented: 2026-10-10 · Custom parts: 2026-10-16 · Updated: 2026-10-18
Implemented and checked by hand on a live job application form (fields, Yes/No, upload, target check, action log) and in the in-app suites, including the guard-rail suite. Custom parts maps and the two commands that draft and check them are covered by offline tests and an in-app suite. The interface below is the contract; changes to it are additive only.
References
- ADR-0022: Automations run as Deno subprocesses: the runner, the permission header (specific 2) and the owned CDP client (specific 7) that this decision amends
- ADR-0023: The workflow engine is in-app: why every result here is plain JSON
- Form-Filling SDK: Delivery Notes (internal): the spike measurements, progress at each checkpoint, and follow-ups
- SDK Overview and the SDK pages after it: the SDK reference, updated in lockstep; Custom Parts and Page Code API compare the two ways a script handles a form not built to standards
- Tranquil Skill: the authoring guide for LLM agents described below
Context
Automation scripts drive the built-in browser through the SDK that ADR-0022 introduced. That
SDK can evaluate code in a page and wait for selectors, and nothing else. Scripts that fill web
forms therefore did all their work inside evaluate, and three classes of failure followed.
- Writes did not register as user input. Every write was a DOM event dispatched from page
JavaScript, so
isTrustedwas false and nothing fired a real focus or blur. Form libraries mark a field as touched and run validation on blur, which UI frameworks map to the nativefocusoutevent.rhfreact-blur Fields stayed untouched and validation messages went stale. Some custom dropdowns ignore programmatic focus entirely and respond only to a real pointer press.rselect - Fields could not be targeted consistently. Each script invented its own addressing: element
IDs,
nameattributes, or labels looked up through a site’s public API. Custom widgets such as Yes/No button groups, custom dropdown widgets, checkbox groups and location autocompletes had no shared handling. Forms embedded in cross-origin iframes could only be reached by importing internal SDK files. - The same helpers were copied everywhere. The native value-setter workaround, synthetic pointer sequences, base64 file transfer and label lookup were repeated across scripts with small differences.
ADR-0022 specific 7 anticipated this: its owned CDP client shipped with “no frames, no real input events”, and named any script needing those as the re-evaluation trigger. That trigger has been reached.
Two further constraints shape the decision:
- Automations are deterministic. A script is plain Deno TypeScript that runs the same way each time. No step resolves a target by guessing, and nothing calls an LLM at run time.
- Most scripts will be written by LLM agents. The SDK’s surface, its errors and its docs are read by an agent as often as by a person, so they have to be small, explicit and stable.
A spike on 2026-10-01 measured how CDP input behaves inside the app’s <webview> tabs (Chromium
139). Its full results are in the delivery notes. The results that shaped this decision:
Input.insertText,Input.dispatchKeyEvent,Input.dispatchMouseEventandDOM.setFileInputFilesall produce events withisTrustedtrue, on the page and inside cross-origin iframes.- Keyboard input is silently dropped when focus is on a host element outside the webview. It
lands once the host calls
webview.focus(). - With
Emulation.setFocusEmulationEnabled,focus()andblur()produce trustedfocusinandfocusouteven when the window is not focused.focus-emulation - Non-active tabs remain fully fillable.
Page.navigatekeeps the CDP target, but a page reload replaces it and closes the socket.
Decision
Add form primitives to Tab and a new Frame, backed by trusted CDP input with verified
writes, and fix their interface as a stable contract. The SDK provides the primitives; scripts
compose them. The SDK works from web standards only; a script describes a form built otherwise in
its own optional custom parts map. Page-side code gets an explicit typed Page scope instead of
the global dom lib, and the browser grant splits into tabs, forms and page-js.
The interface is the contract
Scripts call these methods on a Tab, or on a Frame returned by tab.frame(), which has the
same shape. The signatures, the Target grammar, the result shapes and the error codes are the
contract this ADR fixes.
type Target =
| string // CSS selector
| { label: string | RegExp; within?: Target } // the field's label or question text
| { role: string; name?: string | RegExp; within?: Target } // e.g. { role: "button", name: "Yes" }
| { text: string | RegExp; within?: Target } // visible text, for elements with no role
| FieldRef; // from fields(); current document only
type Value = string | boolean | string[];
tab.fields(opts?: { within?: Target }): Promise<Field[]>
tab.clickables(opts?: { within?: Target; all?: boolean }): Promise<{ clickables: Clickable[]; total: number }>
tab.text(target: Target): Promise<string> // visible text, needs only tabs
tab.attr(target: Target, name: string): Promise<string | null>
tab.fill(target: Target, value: Value, opts?: { sensitive?: boolean; timeout?: number }): Promise<Filled>
tab.clear(target: Target): Promise<Filled>
tab.upload(target: Target, path: string | string[]): Promise<Filled>
tab.click(target: Target): Promise<void>
tab.press(key: string): Promise<void>
tab.frame(url: string | RegExp | URLPattern): Promise<Frame | null>
tab.useCustomParts(...maps: CustomPartsMap[]): void // also on Frame; frame() inherits
tab.evaluate<T>(fn: (page: Page, ...args: Json[]) => T | Promise<T>, ...args: Json[]): Promise<T>
tab.waitFor(target: Target, opts?: { state?: "visible" | "hidden"; timeout?: number }): Promise<void>
tab.waitFor(fn: (page: Page) => unknown, opts?: { timeout?: number }): Promise<void> // page code
ui.confirm(message: string, opts?: { timeout?: number }): Promise<boolean> // false unanswered or unattended
context.result(value: Json): Promise<void> // shown in the Runs panel
type Kind = "text" | "select" | "checkbox" | "radio" | "buttons" | "file" | "other";
interface Field {
ref: FieldRef; label: string;
target?: Target; // verified to match this field only; absent if none exists
alternates?: Target[]; // other verified targets on stable attributes
kind: Kind; required: boolean; multiple: boolean; // multiple: pass a string[]
value: Value | null; empty: boolean;
sensitive?: true; // a secret: withheld from listings, filled as sensitive
options?: string[];
inputType?: string; maxLength?: number; pattern?: string; hint?: string; placeholder?: string;
invalid: boolean; error?: string;
}
interface Filled { label: string; kind: Kind; value: Value | null; invalid: boolean; error?: string }
interface Clickable { role: string; name: string; target?: Target; alternates?: Target[] }
interface CustomPartsMap { // selectors searched near each field; all optional
value?: string; values?: string; multiple?: string; open?: string; clear?: string;
unstableIds?: RegExp;
}
class FieldError extends Error { code: ErrorCode; candidates?: string[]; options?: string[] }
type ErrorCode = "not_found" | "ambiguous" | "stale_ref" | "covered" | "not_editable"
| "no_option" | "not_committed" | "timeout" | "not_permitted";
type Json = string | number | boolean | null | Json[] | { [key: string]: Json };
// FieldRef is opaque; Page is the exported page-scope declaration described below. Each method has one rule a reader can hold in their head:
filleither commits a value it has read back, or throws aFieldErrorcarrying one of the nine codes. It works out the widget itself: text and date inputs, native selects, comboboxes and autocompletes, checkboxes and checkbox groups, radio groups, and Yes/No button groups.uploadtakes a file path, which must be readable under the script’sread=grant.clearempties a field, deselects options and removes uploaded files.clickandpresscover everything that is not a field value;clickableslists what there is to click, each with a verified target.waitFor(target)waits until a target is visible (or, withstate: "hidden", gone). It acts on nothing, so any visible match will do, and a timeout says what it found instead.framereturnsnullwhen no frame matches, so(await tab.frame(pattern)) ?? tabreads naturally.sensitive: truekeeps the value out of results, errors and logs.
A separate test-only import, tranquil/automation/testing, runs the same field resolver over
saved HTML (a page saved before any input), so a script’s targets can be tested without a browser: fieldsOf(html) and fieldOf(html, target) answer for one page, and checkTargets(targets, pages) checks every target against several states of a form. Each takes { customParts } for a form that needs a map.
Primitives only. The SDK has no whole-form method. Filling several fields, deciding whether to
carry on past a failed one, and working out what is still required are the script’s decisions:
a sequence of fill calls, a try/catch on FieldError where a script wants to continue,
and fields() to read what is left. A wrapper would make those decisions for every script, and
would be the one method that swallows errors instead of throwing them.
Stability policy. Changes to this surface are additive only. An error code is never removed or repurposed. Everything below this section, under “How it works”, is implementation and may change without notice to scripts.
Deterministic means no guessing
- An ambiguous or missing target is an error that lists the nearest candidates as ready-to-paste targets. The SDK never silently picks the closest match and never retargets a stale reference.
- Waits are conditions with timeouts, never fixed sleeps.
- A write that changed the page is never retried automatically.
- There is no automatic dismissal of popups or banners. A covered target fails with
coveredand names the element on top.
Saved scripts target by label, and every target handed out is verified
Saved scripts address fields by label or by role and name, which reads well in review and
survives markup changes. FieldRef values from fields() exist for exploring a page within one
run, and are never persisted.
- Verified before shown. Every
Field.targetthatfields()returns has been resolved against the whole document and matched that field and nothing else. When a label repeats, the target gains awithinanchored on the nearest stable container. When nothing unique exists,targetis absent rather than wrong. - Alternates for copy changes and translations.
Field.alternateslists further verified targets on stable attributes (a non-generatedid,name,autocomplete, or a test hook such asdata-testid). Ids that frameworks generate, such as a framework’s:r7:-style counters, UUIDs and numeric counters, are never offered, nor are ids a script’s custom parts map marks asunstableIds. These are choices for the author; at run time a target still resolves exactly or fails. - One visibility rule. Every kind of target matches only elements a person could see, file
inputs excepted. A CSS or role target that matches only hidden elements waits, then fails with
timeout. - Labels in other languages are matched with a
RegExpcovering the variants, for example{ label: /^(e-?mail|courriel)/i }. - Names follow the W3C rules. The names
{ role, name }and{ text }targets match are computed by the W3C accessible-name algorithm (accname 1.2),accname the one browsers and screen readers use, and roles by the HTML role mapping. Run offline against the W3C conformance tests,wpt-accname it gives the expected name for every form field (122 of 122) and 415 of all 470 cases. The rest need stylesheet CSS, which offline tests cannot apply, or are proposals not yet final. A test fails if the score drops.{ label }targets add, on top, the question text above a field, which many application forms rely on and the standard does not cover. - Matching is exact by default, ignoring case, accents, extra spaces and a trailing
*,:or “(optional)“. A substring default would make “Name” match “First name” and “Last name”; aRegExpcovers variants. - Secrets stay out of listings. What
fields()lists reaches the authoring LLM’s context, so it withholds the value of passwords, card numbers and one-time codes (sensitive: true).
Page code gets an explicit Page scope
TypeScript cannot limit the dom library to page-side callbacks: any lib setting or /// <reference lib="dom" /> applies to the whole program.ts-lib-global Microsoft’s @types/web is published as a global lib only, and importing individual types from it as a
module is an open request.types-web
Instead, evaluate and waitFor callbacks receive page as their first argument, typed by an
exported Page declaration:
- DOM tree and form-element types come, type-only, from a Tranquil fork of deno-dom, which exports them as real module classes.deno-dom The fork adds the form elements and ARIA reflection that upstream lacks.
- Browser-only members that a parser cannot provide (layout, focus, live validity, timing, events) are declared in one Tranquil file, grouped by purpose. Network, credential, storage, navigation and clipboard APIs are separate named groups.
- The global
domlib is removed from the seeded configuration. A baredocumentbecomes a type error, so every web API a script touches is either apage.access or a declaration in that file.
Page is a type contract, not a sandbox: at run time the callback receives the real global
object.
The SDK works from web standards; scripts map the rest
The SDK finds and drives fields by HTML, ARIA and the W3C accessible name, plus generic conventions
common across many sites (class words such as question, active, required, error, and a
group of short buttons under one question). It names no framework, UI library or site, in code or
comments; a test fails if it does.
A form built some other way is described by the script:
- A custom parts map (
tab.useCustomParts(map)) names where a field keeps what standards don’t: its shown value (value,values), what marks it as taking several (multiple), what opens it (open), its clear button (clear), and ids the page regenerates (unstableIds). - It is optional, and only useful for a form that isn’t built to standards when the default
fill,clearandfields()should still drive it. - It is data, never code. The SDK only looks its selectors up, near each field, in its own
isolated world. It needs no grant beyond what
fill,clearandfields()need, keeps every guarantee they give, and can’t become a way aroundpage-js. Behavior a widget needs is page code. - It is the user’s own code, in the script or in a module of theirs that the script imports
(declared in
read=). Tranquil ships no maps and matches none to pages.
Two commands help a person write one:
- SDK: Map Custom Parts drafts a map from the live page in the active tab. It is a
built-in script that runs through the normal runner with
@permissions tabs, so it can only read. It keeps a selector only when it verified it: near each field it covers it finds exactly what that field showed, and near no other field anything. The draft opens as TypeScript, with the markup of anything it couldn’t map. - SDK: Check Custom Parts runs a script with its SDK write methods (
fill,clear,upload,click,press) held back, checking each target and, for a dropdown, the parts that method needs, and logs the field’s markup for any part missing.
browser splits into grants, and sites and submit are enforced
The permission header gains four capability keys and one value key. The table shows what each one allows and which SDK methods require it.
| Key | Allows | Methods |
|---|---|---|
tabs | Open, find, navigate and close tabs; read pages | tabs.*, goto, url, title, screenshot, fields, clickables, text, attr, frame, waitFor(target) |
forms (implies tabs) | Input into pages, never a submission | fill, clear, click, press, upload |
submit (implies forms) | Submitting a form: a submit button, Enter in a field | click, press that submit |
page-js (implies tabs) | Arbitrary JavaScript in pages, inside the user’s signed-in session | evaluate, waitFor(fn) |
browser | Unchanged: an alias for all of the above | Everything |
sites= (value) | The only sites a script may act on: sites=jobs.example.com,*.example.org | Every SDK form method, evaluate, goto, tabs.open |
The SDK checks the grant before each call. A Tranquil lint plugin and the consent prompt list the sensitive page APIs a script uses, the sites it is limited to (or that it has no limit), and whether it may submit.
sites=andsubmitare checked by the SDK before acting. Every SDK form method andevaluatereads the page’s address first and refuses withnot_permittedoutside the declared sites; a form in an iframe counts by the frame’s own site. Withoutsubmit,clickandpressrun with a submit guard in the page that stops any form submission they cause (captured on the window, ahead of the page’s own handlers) and refuse withnot_permitted. A click on a button named like Submit, Send, Pay or Place order is refused before it happens. Withsubmit, the guard and the name rule still apply until the person says yes to the run’s most recentui.confirm, so a script that asks and then submits after a no is refused. A submission a page sends some other way (a script that posts on click, with no form) is not caught; the name rule is the backstop, and thesubmit-unconfirmedlint rule flags a script that may submit but never asks.- The header itself is not protection. An LLM writes the header along with the script, so it can ask for anything. What protects the person is the consent prompt (they approve the listed grants, sites and submit) and the checks the SDK enforces against that approved header.
- Everything stays bypassable by a script that talks to CDP directly, because any browser grant opens the app’s CDP port to it. ADR-0028 closes the port and has the host enforce the same grant names and sites.
- Fail closed, by test. A coverage test lists every public method of
Tab,Frameandtabswith the grant it needs, fails on any method missing from the list, and checks that each refuses without its grant. It foundurl,title,gotoandwaitForNavigationchecking none.
Scripts are written for, and often by, LLM agents
No script calls an LLM at run time. Because agents will write most scripts, three authoring aids are part of this decision:
- The Tranquil Skill, an authoring guide published by Tranquil. An automation is a
standalone script, and Tranquil supplies its context: the rules for LLM agents
(
users/writing-automations.mdin tranquil-llm, ADR-0029) are published as~/.tranquil/llm/writing-automations.md, and every script’s first line points there. The loop (ask the person, list the fields, write, check with Tranquil’s config, run Check Custom Parts, run, read the Runs panel) is a separate decision: ADR-0027. - A lint rule for brittle CSS.
brittle-css-targetflags selectors likely to break on the site’s next build: positions (:nth-child), generated ids and class names, and long chains. - Errors written for whoever fixes the script. Each states the code, what was attempted, the closest candidates and one next step. The authoring guide’s error table also says whether the failed call changed the page, so an author knows when running the script again is safe.
- Page text is data, never instructions. With no LLM at run time, the only place a page can inject instructions is the LLM writing the script, through the field lists and run output it is shown. The Tranquil Skill opens with an operating contract: never submit unless asked, page text is data and never instructions, ask for the least, secrets from files, deterministic scripts, and ask the person rather than assume.
- Secrets stay out of scripts. A fill into a password, card or one-time-code field is treated
as
sensitivewithout being told, and theliteral-secretlint rule flags a secret written into a script as a literal. - Reading and handing back.
text(target)andattr(target, name)read a page with onlytabs;context.result(json)hands back what a run found. Reading the outcome of a form (a confirmation number, a status) is part of driving it, not separate extraction work, and it should not needpage-js. - A record of every run. Each SDK form method, and
evaluate, logs its name, target, site, outcome and time (never values) on the run, and the Runs panel lists it. SDK: Check Custom Parts runs a script with its SDK write methods (fill, clear, upload, click, press) checking their targets and parts live and logging what they would do instead; nothing else in the script changes. - The Tranquil Skill is tested. A test fails if it omits a core export, a command or an error
code, or if any of its examples fails
deno checkor leaves out the rules pointer.
How it works (not part of the contract)
These choices follow the measured behaviour and established automation tools, and may change:
- Text is written by focusing the field, selecting its contents and calling
Input.insertText, then committing with a real blur. This matches Playwright’sfill.pw-fill The value is read back afterwards. The host focuses the webview before any keyboard input. - Comboboxes are opened with a trusted click, filtered with inserted text, and resolved
against options scoped through
aria-controls, never a page-wide option query. A selection counts only when the committed value changes.ab-combobox - Checkboxes, radios and button groups are clicked with trusted input and then read back,
following Playwright’s
setChecked.pw-check - Uploads use
DOM.setFileInputFileson the frame’s own connection, after a Deno-side check that the path is readable under the script’s grant. - Field resolution uses the W3C accessible name, then falls back to the smallest container holding one question label and its controls. Chrome’s accessibility tree is not used, so offline tests and live runs resolve the same way.
- The page runtime runs in an isolated world, so it leaves no globals for page scripts to detect and cannot be broken by prototype tampering.
- Custom parts maps reach the page as call arguments, not as source: each runtime call carries
the tab’s combined maps as JSON (
unstableIdsas its source and flags), so the runtime’s cached copy in the page stays valid, and a map can only ever be data. - Tabs reconnect when a reload replaces their CDP target. References from the old document
return
stale_ref.
Fallbacks are deliberately limited. The SDK never fires a synthetic blur (UI frameworks listen for focusout), never tries to forge isTrusted (the attribute is unforgeable),dom-istrusted and
never syncs a framework’s internal value tracker to the new value, which suppresses onChange.react-tracker
Out of scope for this version
- Shadow DOM, open or closed: forms built from web components cannot be resolved yet, and saved HTML does not include shadow roots. Cross-origin frames nested more than one level deep.
- WebMCP tools. Calling tools that a site registers through WebMCP, or reading its declarative form attributes, waits until sites adopt it and the app’s Chromium (139) supports it (the origin trial starts at Chrome 149).
- A general whole-page snapshot. If one is added later,
fields()stays the form-scoped view. - Network interception. No request blocking, rewriting or mocking, and no capture of
responses (the CDP
FetchandNetworkdomains). This includes the non-DOM routes: calling the endpoints a page already uses, or reading its network responses instead of its DOM. This decision makes driving the DOM first-class; those routes are future work under their own ADR. - The filtered CDP proxy. Decided in ADR-0028, as no port at
all: the host attaches Electron’s in-process debugger to a run’s own tabs and enforces the
grants and
sites=itself. Until it lands, a script that bypasses the SDK can bypass them. - Future work, recorded so it is not lost:
- a run-time secret prompt: a person types a password or one-time code into a host dialog and it goes straight to the page, never through the script, its memory or the logs;
- downloads behind a login;
- a signal for “a bot check is in the way” (today it surfaces as
not_found), and a wait for the network to go quiet.
Options considered
Grow the owned CDP client with a narrow input and form layer (chosen). It keeps the direct data plane from ADR-0022 and its webview-guest identity, adds only what forms need, and every behaviour is measured inside the app’s own webviews.
Adopt a full browser driver. Rejected. Webview-guest target identity is already custom, the driver’s assumptions about pages and frames would have to be worked around, and the dependency surface is far larger than these primitives need.
A whole-form method (
fillForm). Built during this effort, then removed before this ADR moved to active. It hid the script’s own decisions (continue or stop on a failure, what counts as unanswered) behind one call, and was the only method that reported errors instead of throwing. The same script is a fewfillcalls and onefields()read.Playwright-style chained locators (
tab.getByLabel(...).fill(...)). Rejected. The lazy locator layer is a larger surface to keep stable and to teach an agent, for no capability the target-object form lacks.Keep page code on the global
domlib and rely on linting alone. Rejected. It leaves DOM globals type-checking in Deno code, where they fail at run time, and makes web API use implicit.An LLM-driven act or observe layer. Rejected. It conflicts with deterministic runs.
Chrome’s accessibility tree as the source of names. Rejected. Names would no longer be computable offline on saved pages, so offline tests and live runs could disagree, and every lookup would add a browser round trip. The SDK computes names itself, scored against the W3C tests.
Bundling a full accname library (Testing Library’s dom-accessibility-api, or a port of Playwright’s role utilities). Rejected for now. The page runtime is one self-contained function, the library needs a browser window object that the offline DOM lacks, and targeted fixes reached every form-field case in the W3C tests.
A network-level domain allowlist (blocking requests to other hosts). Rejected for the user’s real browser: it would affect their own tabs and break sites that load from CDNs, and agent-browser itself refuses it when attached to a signed-in browser.
sites=limits where a script acts instead, and Deno’snet=already limits the script’s own network access.A model that screens page text for prompt injection. Rejected. It calls a model at run time, and the one examined lets content through unscanned when the model call fails. Fencing and the operating contract defend the authoring LLM without one.
Run-time token threading against stale state (every write carrying the last-seen state token). Rejected. It solves an agent thinking between a read and a write; a script resolves at the moment it acts, and
stale_refcovers references that outlive their page.Keeping references from a page snapshot in saved scripts (agent-style
@e12refs). Rejected. They are valid for one page load; the tools that use them say never to persist them.FieldRefstays a within-run handle, and scripts keep verified targets.Authoring aids built during this effort, then removed as more machinery than value:
- a JSON Schema view of a form (
schema()):fields()already carries the same facts, and nothing used the schema; - a
submit-undeclaredlint rule the run-time refusal already covered; - an
llms.txtat the site root: it only helps an agent that starts from the web, and it was a second copy of the rules to keep in sync; - a command line for agents in a terminal: not taken, see ADR-0027 (options considered).
- a JSON Schema view of a form (
Knowledge of particular widgets in the SDK. The first runtime read one dropdown library’s class names and skipped several libraries’ generated ids, learned from the one application form it was built against. Removed: the SDK can’t follow every widget on the web, it would grow with every site, and the person scripting a page can describe it in a custom parts map.
Driving non-standard fields with
evaluatealone. Not taken as the answer: every such field would needpage-js, lose read-back and the guard rails, and run for real during a check. Page code stays the tool for behavior, not for a field that only keeps its parts elsewhere.Maps bundled with the SDK, one per known widget. Rejected for the same reason as built-in knowledge: the SDK can’t cover the web’s variations.
Map entries that may be code. Rejected: a function in a map would run with the SDK’s reach in the page, so it would need
page-jsand a line in the approval prompt; selectors cover every part found so far.A registry that matches maps to pages by site or by recognizing a widget. Rejected: the script decides which map applies, as it decides anything else.
The map creator as an SDK form method or a command line. Not taken: an app command is enough for the person who runs it, nothing is added to the script API, and the command line was cut (ADR-0027).
Comparisons with other tools
Before this ADR moved to active, the SDK was compared three times with related tools, each read at a pinned commit. The delivery notes record each comparison in full; what they changed:
- Selector Forgesf-readme confirmed the split between authoring and running (“the AI
proposes and ranks; it never gets the final word on correctness”). It led to verified targets
with
within, alternates on stable attributes,devtools-recorder thebrittle-css-targetlint rule and one visibility rule for every kind of target. - web-ai-sdkweb-ai-sdk led to the extra facts on
Field(multiple,inputType,maxLength,pattern,hint,placeholder). - Agent browser tools and locator conventions (agent-browser,agent-browser browser-use and
jev-ultrafast,browser-use Stagehand,stagehand Playwright,playwright Testing
Librarytesting-library and others) confirmed targets by role, label and text over CSS, and a
fixed script at run time. They led to the
{ text }target,clickables(),waitFor(target)and the W3C name computation with its conformance test.accname - Guard-rail review (vibesurfer,vibesurfer spel,spel agent-browser’s safety
featuresab-security and Firecrawlfirecrawl) exposed three wrong assumptions: that reading
a page could wait, that the header protects the user (an LLM writes it), and that no model at
run time means no prompt injection (the authoring LLM reads page text). It led to
text,attrandcontext.result, enforcedsites=andsubmitwithui.confirm, the permission coverage test, the operating contract in the authoring guide, the action log and the target check.
All of them still take the first match when several elements fit, and none reads a value back after writing it; the SDK does both.
Consequences
- Amends ADR-0022 specific 7. The owned CDP client now includes frames, input events, file inputs, isolated worlds, focus emulation and the accessibility domain. It still has no network interception.
- Amends ADR-0022 specific 2. The permission header gains
tabs,formsandpage-js, withbrowserkept as an alias so existing scripts are unaffected. - One breaking change.
evaluateandwaitFor(fn)callbacks now takepagefirst, and thedomlib is gone from the seeded configuration.deno checkfinds every affected call site. Scripts that import the SDK from a working tree break until they are migrated. - Filling brings the tab’s window forward, because keyboard input needs the webview focused at the host level.
- Results are plain, small JSON, so they can be journaled as workflow-engine step results later.
tabs/forms/page-jsare advisory, andsites=/submitare enforced by the SDK, until ADR-0028 lands; the ADR fixes the names now so the proxy can enforce them unchanged.browserincludessubmit, so existing scripts keep working, and the consent prompt now says so.- Forms not built to standards need a map. A script that relied on the SDK knowing a widget needs a custom parts map; the failure names the part and the command that drafts one.
- Re-evaluate if a site’s controls cannot be driven by trusted input plus read-back, if a
custom parts map starts to need behavior rather than selectors, if the
Pagedeclaration starts to need large parts of the DOM, or if the deno-dom fork becomes expensive to maintain.
- A widely used form library treats both
blurandfocusoutas blur events and only marks fields touched on them: createFormControl.ts, skipValidation.ts↩ - A widely used UI framework registers its blur handler on the native
focusoutevent: DOMEventProperties.js↩ - A widely used dropdown widget opens its menu on
mousedown, notclick: Select.tsx↩ - Chromium suppresses focus events in pages without focus: Chromium review 26149002. Playwright enables focus emulation per page for the same reason: crPage.ts↩
- Accessible Name and Description Computation 1.2 (W3C)↩
- web-platform-tests
accname/nameat commit 2a5ac6d: accname/name, vendored in the SDK’sforms/testdata/wpt-accname↩ - TypeScript issue 33901:
/// <reference lib="dom" />has global effects↩ - TypeScript-DOM-lib-generator issue 1207: importing types from
@types/web↩ - b-fuze/deno-dom, forked as tranquillabs/deno-dom↩
- Playwright’s
fillselects the field’s text and callsInput.insertText: dom.ts↩ - The commit-verification approach follows agent-browser pull request 1770↩
- Playwright’s
_setChecked: dom.ts↩ - DOM Standard:
isTrusted↩ - A widely used UI framework suppresses its change event when the tracked value equals the node’s value: inputValueTracking.js↩
- Selector Forge README, “How it works” and “Trust boundary”, at commit d83a01d: README.md↩
- Chrome DevTools Recorder’s selector computation and its test-hook attributes: SelectorComputer.ts↩
- web-ai-sdk.dev, source at commit cd417b2: obetomuniz/web-ai-sdk↩
- vercel-labs/agent-browser at 526157cf↩
- browser-use at v0.13.10 and jev-ultrafast↩
- browserbase/stagehand at 03364090, and Browserbase’s write-up of the
browseCLI: browse.sh↩ - Playwright’s locators guide and accessible-name implementation; Playwright MCP↩
- Testing Library’s query priority and dom-accessibility-api↩
- frane/vibesurfer at 4851123↩
- Blockether/spel at 1e0203d↩
- agent-browser at 6d3e22c: security.mdx and trust-boundaries.md↩
- firecrawl.dev, firecrawl/firecrawl at b60ef4e and firecrawl-mcp-server at 6a58e3b↩