Reading the page
Sometimes a script needs to look at the page rather than change it — to feed an LLM the current state, diff a layout between two snapshots, figure out what's under a coordinate, or read back what the user highlighted. browserscale ships a small set of read-only methods for exactly that. They all return data and never poke the page.
GetObservationis the easy on-ramp: a compact, agent-friendly view of what's visible right now — headers included, so it also answers "where am I?".GetDOMis the full CDP DOM tree as JSON — use when you need every node, not when you need a quick overview.GetDOMHashis a 16-char fingerprint of the DOM tree — pair it withGetDOMto skip unchanged snapshots.InspectAtPosition,HighlightNodeandGetSelectioncover the live-UI cases: hit-test, debug overlay, copy what's selected.- Reading methods don't wait. Pair them with
Waitwhenever you depend on something specific being there.
GetObservation — the agent-friendly summary
The first thing to reach for on an unfamiliar page, and the cheapest way to re-read the current state afterwards. The server walks every frame, keeps what is actually visible, and returns it as text meant to be handed to a model unchanged.
obs, err := browser.GetObservation(ctx)
if err != nil {
log.Fatal(err)
}
fmt.Println(obs)const obs = await browser.getObservation();
console.log(obs);Each frame opens with header lines, then emits one line per element, indented by tree depth:
# frame 8F03BF2EEB5ADC9CE47B15775289BBD3 https://example.com/register
# title "Account registration"
# scroll y=1200/8400
input#email[47] type="email" name="loginId" value="a@b.com" required click "E-Mail"
input#agree[51] type="checkbox" checked="false" required click "Accept terms"
select#pref[62] name="prefecture" value="13" options="01:Hokkaido,02:Aomori,…" click
button[70] type="submit" click "Continue"
Because the headers already carry the URL, the title and the scroll
offset, you rarely need an Evaluate round-trip just to work out where
a flow ended up. The title line is omitted when the document has none,
the scroll line when the frame doesn't scroll.
Reading a line
The leading token is tag#id[backendNodeId], followed by attribute
keys and then bare boolean flags. Attribute keys are role, type,
name, placeholder, value, checked, options, href, src,
frameId (on iframes, naming the frame section further down) and
hidden.
Two details matter more than the rest. value is read from the live
IDL property, so it reflects what was actually typed rather than the
initial markup — which is how you verify a Fill landed; on password
fields it is reported as a length, e.g. value="(8 chars)". And the
trailing quoted string is always the element's label or text, never
its value or placeholder, so an empty field and a prefilled one stay
distinguishable.
The bare flags are disabled, required, readonly, selected,
offscreen, click, id-not-unique and id-not-selectable.
click means the element carries an interactivity signal: it is a
control or link, or it has a role, a tabindex, a click handler or an
introduced cursor:pointer — which is how <div onclick> buttons get
caught. offscreen appears only on interactive elements and means you
must scroll before acting.
What gets included
Traversal follows the flat tree, so open and closed shadow roots
are included; user-agent shadow roots are not. <select> options are
enumerated explicitly (capped at 60 per select) because you need them
to call SelectByValue. Generic wrappers with no interactivity signal,
no id or role and no own text are omitted, and their children reported
at the parent's depth. Element text is emitted exactly once: a
container only shows text that no descendant row already carries.
Subtrees without a layout box (display: none) are pruned entirely.
Elements that are merely unseeable — visibility: hidden, opacity: 0,
zero-sized — are reported only when they are still interactive, and
carry a hidden="<reason>" attribute. That is deliberate: when a click
fails, the reason is in the observation instead of the element having
silently vanished from it.
Budgets
The limit that normally binds is maxTotalTokens (default 8000),
a budget across all frames measured in estimated tokens rather than
characters — the same character count is worth roughly four times as
many tokens in CJK text as in ASCII, so a character limit would mean
something different on every page. Frames are visited in tree order and
each gets whatever is left, so a page full of iframes can't multiply the
limit.
maxElementsPerFrame (default 800) is a safety net against runaway
documents, and maxTextLength (default 300) caps human-readable
strings; identifier-like attributes such as type and name have their
own shorter cap and are unaffected.
Running out of budget does not cut the walk off in document order — that
would reliably drop the end of the page and with it the submit button.
Instead the remainder of the frame degrades to interactive elements only,
marked by a # budget spent line. When you see one and still need the
rest, scroll and observe again, or narrow the walk:
// Just what's on screen right now.
obs, _ := browser.GetObservationWith(ctx, browserscale.ObservationOpts{
ViewportOnly: true,
})// Just what's on screen right now.
const obs = await browser.getObservation({ viewportOnly: true });Scoping to a subtree
Omit the scope fields for the whole page (the default). After the first
full look, pass exactly one of Selector, JSExpression or
BackendNodeId to observe only that element's subtree — follow-up looks
at a form then cost the form, not the ads around it. Addressing matches
Click / Fill. Child iframes reached inside the scope are still
visited (so a checkout form keeps its payment iframe); frames outside
the scope are not. Text mode marks a scoped result with a
# scope tag[backendNodeId] header line.
// Re-read just the registration form.
obs, _ := browser.GetObservationWith(ctx, browserscale.ObservationOpts{
Selector: "form#register",
})
// Or by a handle from the previous observation:
obs, _ = browser.GetObservationWith(ctx, browserscale.ObservationOpts{
BackendNodeId: 120,
InFrame: frameId,
})// Re-read just the registration form.
const obs = await browser.getObservation({ selector: "form#register" });
// Or by a handle from the previous observation:
const again = await browser.getObservation({
backendNodeId: 120,
frameId,
});Choosing what to target
backendNodeId — the 47 in input#email[47] — is a handle for the
current session. Pass it straight to an action via Node(...) and you
never guess a selector:
_, _ = browser.Click(ctx, browserscale.Node(47).InFrame(frameId))await browser.click(node(47).inFrame(frameId));It does not survive a new document, though, so it is worthless in a
script you intend to run again. Prefer the anchor you can still use
tomorrow: name, then an id flagged with neither id-not-unique nor
id-not-selectable, then a label or text relation — and never a
generated class name. The two id flags are worth internalising:
id-not-unique means the id is duplicated in this tree scope and #id
happens to resolve to this element, while id-not-selectable means it
resolves to a different one, so the element can't be reached by its id
at all.
The moment the observation is in front of you is the only one where
name, the id flags and the label are all visible at once, so pick the
durable target then rather than later. Acting through it right away has
a second payoff: every call that worked during exploration is already a
line of your script, and since actions return the backendNodeId they
resolved to, comparing that against the observation proves the anchor
hits the element you meant.
The JSON form
Passing format: "json" returns the same rows with explicit keys, plus
per-frame counts, a degraded flag and a truncated reason
(max_elements, node_budget or token_budget). It is roughly twice
the size of the text form for identical information, so it is meant for
programmatic consumers — feed the text form to a model.
obs, _ := browser.GetObservationWith(ctx, browserscale.ObservationOpts{
Format: "json",
})
var parsed Observation
_ = json.Unmarshal([]byte(obs), &parsed)const obs = await browser.getObservation({ format: "json" });
const parsed = JSON.parse(obs);GetDOM — the full CDP tree
When the observation isn't enough — you need every node, the
nesting, the full attributes — switch to GetDOM. The payload is a
JSON string in standard CDP DOM.Node shape, with same-origin
<iframe> / <frame> / <object> children inlined into the same
tree. Cross-origin (out-of-process) frames stop the tree; call
GetDOM again with that frame's frameId to descend.
// Full tree of the main frame.
domJson, err := browser.GetDOM(ctx, "", -1)
if err != nil {
log.Fatal(err)
}
// domJson is a CDP DOM.Node tree — feed it into anything that speaks CDP.// Full tree of the main frame.
const { dom } = await browser.getDOM();
// dom is a CDP DOM.Node tree — feed it into anything that speaks CDP.The return shape differs across the two SDKs in one cosmetic way: Go
gives you the JSON string directly, TypeScript wraps it in a
{ dom, hash } object where hash is reserved for future use and is
not populated by this call. Either way, when you want the
fingerprint, call GetDOMHash.
Two parameters tune the call:
frameId(Go: positional, TS: positional) — empty / omitted targets the main frame. Use a specific frame's id to descend into an OOPIF.depth(Go: positional, TS:opts.depth) —-1for the full tree,0for the root only,Nfor the root plus N descendant levels.
// Just the top two levels — cheap probe before pulling the whole thing.
shallow, _ := browser.GetDOM(ctx, "", 2)// Just the top two levels — cheap probe before pulling the whole thing.
const { dom } = await browser.getDOM("", { depth: 2 });GetDOMHash — the change detector
GetDOMHash returns the first 8 bytes of sha256(dom) as a 16-char
hex string. Computing the hash on the server is dramatically cheaper
than transferring the full tree, which makes it the right primitive
for "did anything change since I last looked?" polling loops.
var lastHash string
for {
hash, err := browser.GetDOMHash(ctx, "")
if err != nil {
return err
}
if hash != lastHash {
lastHash = hash
domJson, _ := browser.GetDOM(ctx, "", -1)
process(domJson)
}
time.Sleep(500 * time.Millisecond)
}let lastHash = "";
while (running) {
const hash = await browser.getDOMHash();
if (hash !== lastHash) {
lastHash = hash;
const { dom } = await browser.getDOM();
process(dom);
}
await sleep(500);
}Don't reach for this as a wait substitute. If you're trying to wait
for "the page to stop changing", use Wait with a CSS or JS
condition for the actual element you care about — see
Waiting.
InspectAtPosition — what's under (x, y)?
A hit-test at a viewport-relative pixel coordinate, returning the
topmost element under that point. Elements with
pointer-events: none are skipped, so the result is the actual click
target — not the visually-topmost node. This is what the live-UI
hover overlay calls under the hood.
res, err := browser.InspectAtPosition(ctx, 200, 300)
if err != nil {
log.Fatal(err)
}
fmt.Println(res.TagName, res.TextContent)
// res.BackendNodeId == 0 means nothing was found.const r = await browser.inspectAtPosition(200, 300);
console.log(r.tagName, r.textContent);
// r.backendNodeId === 0 means nothing was found.The result carries everything you need to act on the element next:
BackendNodeId, FrameId, TagName, trimmed TextContent,
post-scroll IsVisible and the bounding rect.
Typical use cases:
- Stream-based UIs where the user clicks on the video feed and the script needs to translate the click into a real DOM target.
- Coordinate-driven recipes (canvas/captcha tile, HTML5 game)
where you want to verify what's actually there before firing a
Click(at(...)).
HighlightNode — the debug overlay
The visual companion to InspectAtPosition. Paints a coloured
overlay on top of the node identified by backendNodeId. The overlay
stays until the next call — pass a non-positive backendNodeId to
clear it.
r, _ := browser.InspectAtPosition(ctx, 400, 250)
_ = browser.HighlightNode(ctx, r.BackendNodeId, r.FrameId)
// ... screenshot or just watch the live stream ...
_ = browser.HighlightNode(ctx, 0, "") // clear the overlayconst r = await browser.inspectAtPosition(400, 250);
await browser.highlightNode(r.backendNodeId, r.frameId);
// ... screenshot or just watch the live stream ...
await browser.highlightNode(0); // clear the overlayPure debugging affordance — HighlightNode only paints visuals, it
doesn't change the page's behaviour. Use it freely in development; in
production scripts there's usually no reason to call it.
GetSelection — read what's highlighted
Walks every frame and returns the first non-empty text selection it
finds. Returns "" when nothing is selected anywhere. Useful for
"copy what the user highlighted" flows and for tests that exercise
selection-based UI (e.g. "did our right-click translate this text?").
sel, err := browser.GetSelection(ctx)
if err != nil {
log.Fatal(err)
}
if sel == "" {
fmt.Println("nothing selected")
} else {
fmt.Println("user selected:", sel)
}const sel = await browser.getSelection();
if (sel === "") {
console.log("nothing selected");
} else {
console.log("user selected:", sel);
}When to reach for which
| You want… | Use |
|---|---|
| A short summary an LLM can read | GetObservation |
| Every node and attribute | GetDOM |
| "Did the DOM change since I last looked?" | GetDOMHash (then GetDOM if it did) |
| The element under a specific pixel | InspectAtPosition |
| A visual marker on a node while debugging | HighlightNode |
| The text the user is currently highlighting | GetSelection |
| To wait for something to appear | Not these — use Wait |
Gotchas
- They don't wait. None of these methods polls for an element to
appear. If you call
GetObservationbefore the page has rendered, you get whatever was visible at that instant. UseWaitfirst for the anchor element you care about. - TS
getDOMreturns{ dom, hash }; Go returns just the DOM string. Cosmetic wrapper difference only — in TS thehashfield is reserved for future use and is not populated bygetDOM. CallgetDOMHashexplicitly in both SDKs. - OOPIFs stop the DOM tree.
GetDOMinlines same-origin frames but stops at cross-origin ones. Recurse by callingGetDOMagain with the OOPIF'sframeId(look it up viaGetPages— covered in Frames & iframes). InspectAtPositionreturnsBackendNodeId == 0for misses. Treat that as "nothing there" rather than an error.HighlightNodeis sticky. The overlay persists until you call again — make sure your cleanup path clears it (backendNodeId = 0) or your screenshots will keep showing stale highlights.
- Evaluation — when you need to run JavaScript instead of (or after) reading the structure.
- Waiting — the explicit pause to put in front of any read that depends on a specific element.
- API reference: Go DOM helpers · TS DOM helpers.