Skip to main content
Every Reticle drive is the same four steps: look (reticle_look { action: "page" } / reticle_look { action: "find" }), act (reticle_act / reticle_act { steps: [...] }), observe (reticle_observe / reticle_observe { action: "network" } / reticle_look { action: "state" }), assert (reticle_assert). Verdicts come from reticle_act_and_wait, reticle_assert, reticle_act { steps } when a step declares expect, and reticle_verify (change/flows). Everything else moves or reads the app and proves nothing. Only verified: "yes" is a pass: unknown means Reticle could not tell, and no-fault means nothing was declared to prove. This page is the full reference for every tool, predicate, action and flag in that loop. Tools outside the ten advertised by default are called through reticle_run({ tool, args }), and examples use the default tool names. If you haven’t set up Reticle yet, start with Getting Started.
Start here instead, unless you want the long form. This page predates the focused reference and still holds the fullest single narrative, but almost everything in it now has a page of its own that is shorter, searchable and verified against a running app: What is still only here: turning existing test cases into agent checks (§11), the security and privacy detail (§15), and real input mode (§18).
On the notation. Examples here are written as reticle_act({ ref, action }), which is shorthand for “call that tool with these arguments”. Over MCP you do not write JavaScript; your client sends the tool name and an arguments object. The shorthand is shorter to read, and the argument shapes are identical either way. If you want examples in the exact JSON your client sends, Recipes has them, and every response on that page was captured from a running app.
Contents
  1. How Reticle helps you
  2. Core concepts
  3. The tools: full reference
  4. The predicate DSL
  5. Actions: full list
  6. Snapshot modes & scoping
  7. Cookbook: real situations
  8. Regression: baselines & diff
  9. Recording a flow
  10. Autonomous exploration
  11. Turning your test cases into agent checks
  12. Token discipline
  13. Best practices & gotchas
  14. FAQ
  15. Security & privacy
  16. Presenter mode, narration & fake clock
  17. Evidence-of-effect, act+await, state, capabilities, replay
  18. Real input mode: native hover & drag

1. How Reticle helps you

You mostly talk to your agent in plain English: “add X and verify it works.” The agent uses Reticle under the hood. Here’s the value, by situation:
  • You stop being the agent’s eyes. Today you build a feature, then you click through the browser to check it. With Reticle the agent checks its own work and only comes back when it’s actually verified, or with a precise reason it failed.
  • Silent breakage gets caught. A console error, a 500 on one locale, a button that quietly disappeared after a refactor: humans skim past these; Reticle asserts on them.
  • The fix loop closes. When something’s wrong, Reticle reports the evidence: the failing network call, the console stack, and (on React) the source file:line to edit.
  • It’s cheap enough to run constantly. ~100 tokens per verified interaction means the agent can verify on every edit, not just at the end (see token-efficiency).
  • Your manual QA becomes automated. The checklist you never turned into Playwright tests? Your agent runs it now (see §11).
Who benefits most: anyone shipping dashboards, internal tools, SaaS apps. Behavior-heavy UIs with lots of forms, lists, modals, and API calls that change often.

2. Core concepts

The loop: look → act → observe → assert.
  1. Look with reticle_look { action: "page" } (what’s on screen) or reticle_look { action: "find" } (find a specific thing).
  2. Act with reticle_act (click/fill/…). It returns a since cursor, a timestamp marker.
  3. Observe with reticle_observe({ since }): everything the app did after that action.
  4. Assert with reticle_assert({ predicate }). Verify it, get evidence.
Refs. Elements are addressed by stable handles like e7. You get them from reticle_look { action: "page" } or reticle_look { action: "find" }, then pass them to reticle_act or reticle_look { action: "element" }. A ref re-resolves to its element across re-renders; if the element is gone, you get a clear error. Evidence, not prose. Every tool returns structured data (counts, the matching network call, the snapshot delta), so the agent reasons over facts. Sessions. Each connected browser tab is a session (named via reticle.connect({ session })). Omit sessionId unless you mean a specific tab. Reticle scopes to your project, prefers the active tab, and refuses when the choice is ambiguous.

3. The tools: full reference

reticle_session { action: "list" }

List connected tabs. → { sessions: [{ sessionId, url, title, lastSeenMs, hidden, focused, throttled, sdkVersion?, sdkBehind?, pendingMarks?, … }], next_step? }. sdkBehind is present when the page’s SDK is a minor release or more behind the daemon, and it names the update command. On a project that was never wired, this call changes no file: next_step names the zero-install lease, and the question to ask the user before you run init. lastSeenMs is the silence since the tab last reported (not time-since-connect); throttled is true when the tab is hidden or has merely gone quiet, and the two are not the same finding: a hidden tab clamps timers/rAF/pointer so an action can land on a page that never advances, while a quiet-but-visible tab is usually still driveable. The accompanying recommendation says which case you are in; treat throttled as a reason to read it, not as a reason to lease.

reticle_look { action: "page" }

A semantic, accessibility-tree view of the page.
  • args: mode?: 'full' | 'interactive' | 'status' (default full), scope? (CSS selector or ref), diff?: boolean, ids?: boolean (add [testid=…] to each control that has one), audit?: boolean (also report text cut off with no ellipsis, controls drawn over each other, and text under WCAG contrast, as audit: [{ kind, ref, what, detail }]), sessionId?.
  • returns: { tree, status: { route, title, visibleDialogs?, overlayHidingPage? }, nodes, truncated, cost: { bytes, tokens } }.
  • diff: true returns only what changed since your last snapshot of the same scope/mode: { mode: 'delta', delta: { added, removed, addedCount, removedCount } } or { mode: 'unchanged' } (no full tree). The first call (and any call after a route change) still returns the full tree. ~99% fewer tokens to re-look after an action; see token-efficiency.md.
  • cost is an estimated size of the result. Re-scope (mode/scope) before reading if large.

reticle_look { action: "find" }

Find elements (Testing-Library semantics).
  • args: by: 'role'|'text'|'label'|'placeholder'|'testid'|'alt'|'component', value, name? (for role), scope?, limit?, sessionId?.
  • returns: { elements: [{ ref, role, name, value?, states, visible, text? }] }.

reticle_look { action: "element" }

Deep detail on one element, including the signals a snapshot/a11y tree omits, so you can tell “present” from “actually usable / on-theme”.
  • args: ref, sessionId?.
  • returns: descriptor + tag + box + occluded (another element covers its center: a z-index/overlay bug) + styles { color, backgroundColor, opacity, cursor, display, visibility } + theme { colorToken, colorTokens, backgroundToken, backgroundTokens, offTheme, tokenCount, themeScope } (compliance vs the app’s design tokens, where offTheme:true flags an off-palette color; the plural fields list every token sharing that colour and the singular ones are null when several do, and themeScope names the theme active at capture) + component { componentStack, source?: { file, line, column } } (with @reticlehq/react).
  • Use it to catch present-but-broken UI: opacity:0 / box 0×0 / occluded:true (invisible or unclickable), cursor not pointer (dead control), offTheme:true (off-design-token color).

reticle_act / reticle_act { steps: [...] }

Perform one action / several in order.
  • reticle_act args: ref, action, args?, refuseWhenThrottled?, sessionId?. → { since, dispatched, settled, settleReason, result, session, warning? } where result = { ok, ref, action, dispatched, settled, settleReason, effect }. The session block { lastSeenMs, throttled, focused } reports tab health on every act; when throttled is true a warning string is also attached. Pass refuseWhenThrottled: true to hard-fail instead of warning (opt-in; default is warn-only so background testing never breaks).
  • reticle_act { steps: [...] } args: steps: [{ ref | target, action, args?, expect? }]. expect takes the same predicate as reticle_act_and_wait until. A step with expect proves its consequence. The call stops at the first expect that fails and returns verified and because. Each step takes ref (from a snapshot/query) or target ({ testid } / { label } / { role, name } / { text }). That is the same locator reticle_act accepts. → { since, dispatched, result } where result = { ok, count, effects: [...], steps: [...] } (one effect per step; each step carries its own dispatched/settled/settleReason).
  • See §5 for the action list.
Dispatch vs settle. (Settle means the page has stopped changing for now.) The action is two phases: the dispatch (the synchronous click/fill, which is what can fail) and the settle (waiting one animation frame so React’s commit lands before we return). The settle is bounded (~200ms): in a throttled/background tab requestAnimationFrame never fires, so Reticle falls back to a timer and resolves anyway. A settle timeout is therefore never an error: reticle_act resolves with settled:false, settleReason:"timeout" and the dispatch (the click) has still landed. Only a real dispatch failure (stale ref, wrong element type) throws. result.effect: best-effort evidence the action landed. All probes are cheap and capture only the immediate effect (one microtask + one rAF after dispatch); async, network-driven re-renders show up in reticle_observe, not here. A verdict drawn over a click with occluded, noSize or offViewport is unknown (verifiedReason: "inconclusive"), never yes: synthetic dispatch reached the target, so the consequence can hold, but no person could have made that click. because names the cause and the fix. A replayed flow step reads the same rule: the step drifts with target_unreachable, so the replay is unknown, never green. Use it to distinguish failure modes: visible:false/enabled:false/targetMatched:false → your action missed; the tool throwing → it never dispatched; occluded:true → the control is covered by something (a real user is blocked even though the synthetic event landed); defaultPrevented:true or all of valueChanged:false/focusMoved:null/domMutatedWithin:0 → the app didn’t react. Clicks run the code, they don’t push pixels. A click/dblclick fires the full pointerdown → mousedown → focus → pointerup → mouseup → click sequence directly on the resolved element, so pointer- and focus-gated handlers fire the way they do for a real user, with no coordinate gesture to be intercepted by the presenter HUD or missed off-screen. This is the default even when native CDP real input is configured (inputMode:"synthetic", inputModeReason:"synthetic-click-preferred"). Before dispatch Reticle hit-tests the click point (occluded) and scrolls an off-screen target in (scrolledIntoView), so a blocked or off-viewport target is reported, never silently “successful”. For the rare case that needs a trusted native click (a native file picker, clipboard, or an isTrusted-gated handler), pass args:{ native:true } to drive it through CDP. hover/drag still use native pointer input (they need real hit-testing). Cookbook: “Did my action even land?”

reticle_observe

The timeline + summary of what happened.
  • args: window_ms? (default 2000) or since? (cursor from an act), filters? (event-type names), max_events? (cap the timeline to the most recent N), sessionId?.
  • returns: { window_ms, events: [...], summary: { network, domAdded, domRemoved, routeChanges, consoleErrors, animations, signals }, cost: { events, bytes, droppedOldest? } }.
  • Output budget. Every result carries a cost:{ events, bytes } hint so you can self-budget your next call. When max_events truncates the timeline, the dropped count is surfaced as cost.droppedOldest, never a silent cap. (The presenter HUD’s own animations are filtered out of the timeline automatically, so observe shows the app, not the instrument.)

reticle_act_and_wait

Act, then wait for a predicate: the whole act→observe→assert loop in one hop.
  • args: ref or target ({ testid } / { label } / { role, name } / { text }), action, args?, until?: <predicate> (omit it to wait only for the page to settle), timeout_ms? (default 4000; 0 = evaluate once), refuseWhenThrottled?, intent?, durable? (after a yes, reload and require until’s element/text/state parts to hold again), sessionId?.
  • returns: { verified, because, effect, verdict, trace, summary, honesty, engineBlindSpots?, since, session, … }. engineBlindSpots names what no channel could see in this run: pixels-unobserved (a <canvas> was on screen) and claim-arithmetic-unchecked (the claim states an amount and no verdict ran a compare). Gate on verified (yes / no / unknown / no-fault; only yes is a pass) and read because for the deciding evidence. effect is the action result ({ ok, ref, action }), verdict is { pass, evidence?, failureReason? } plus verifiedReason, the clause that decided it, trace is the reaction report of everything the app did after the action, and session is the tab-health block { lastSeenMs, throttled, focused } (with a warning when throttled). A failing verdict still returns effect + trace so you can see what did happen. The predicate is automatically floored at this act’s cursor, so it only matches events the action actually caused.

reticle_assert { action: "wait" }

Block until a predicate holds, or time out. It looks back at the recent buffer and forward. It returns verified, because and pass, graded as reticle_assert grades them. Gate on verified. One call waits for a limited time. When it stops before the predicate holds, it returns resume_ms: call again with timeout_ms: resume_ms and the same since to keep waiting. A wait is not saved to the journal. Use it to let the page settle, and put the proof on reticle_act_and_wait or reticle_assert.
  • args: predicate, timeout_ms? (default 4000, up to 600000), since?, sessionId?.
  • No stale-signal false passes. By default the evaluation window is floored at your last act’s cursor, so a signal, network, console or animation event buffered before the action can never satisfy the predicate. Pass an explicit since (an act/observe cursor) to widen or narrow the window deliberately. Element/text predicates query the live DOM and are unaffected by since.

reticle_assert

Verify a predicate; optionally wait for it.
  • args: predicate, timeout_ms? (0 = evaluate once), since?, intent?, sessionId?.
  • Same since default as reticle_assert { action: "wait" }: scoped to your last act so a stale buffered event can’t fake a pass; override with an explicit since.
  • returns: { verified, because, pass, evidence, contradictions?, coverage?, engineBlindSpots?, failureReason?, session, warning? }. On failure includes a near-miss (e.g. “found the dialog but not visible”, or “no button named ‘Submit’; saw: Cancel”). The session block { lastSeenMs, throttled, focused } reports tab health on every assert; when throttled a warning is attached so you never assert against a tab that is silently no-oping.
  • intent declares what the change was FOR, inline. Pass a sentence in your own words and it lands in .reticle/intent/ (one directory per subject, a flow’s name being its subject), the same git-checked ledger reticle_intent writes, before the verdict is drawn; a green verdict then marks it proved and names itself as the proof. Pass the id of an intent you already declared instead, to point several verdicts at one statement rather than restating it. reticle_act_and_wait takes the same argument. Omit it and nothing is written.
  • Read verified, not pass. pass says the predicate held; verified says whether that means anything. It is "no" when a channel contradicts the assertion (a failed write under a green screen, a batch whose body reports per-item failures, a request still in flight), and "unknown" when the outcome could not be known yet: a 202 Accepted that has not reconciled, or a write whose response body was never recorded. because names the deciding evidence in one sentence.

reticle_reconcile

Compare what the API returned against what the page renders.
  • args: since?, urlContains?, scope?, witness?, sessionId?.
  • returns: { mismatches, compared, note?, witness? }.
  • scope is a ref or CSS selector, for example "#status". The tool then compares only against that subtree. Use it when the whole page is too large to read completely, for example a dashboard of large native selects, and the API feeds one region. Every cut branch under the scope is still read. A scope that matches no element is refused, never read as an empty page.
  • Catches the class no status code and no assertion can reach: a USD 7997 amount rendered as ₹79.97, or a record the API calls on_hold displayed as "pending". Both sides agree on the digits; only the meaning differs, so every other channel reports success.
  • Needs response bodies: connect({ captureNetworkBodies: true }). When nothing could be compared it says so in note rather than returning an empty, clean-looking result over data it never read.

reticle_observe { action: "network" } / reticle_observe { action: "console" } / reticle_animations

Fast targeted lookups without a full timeline.
  • reticle_observe({ action: "network", since?, method?, urlContains?, status?, bodies? }) → { calls }. Pass bodies: false for a body-free listing (method/url/status/timing only); bodies dominate the payload, so the common “did POST /x return 200?” read gets much cheaper.
  • reticle_observe({ action: "console", level?, since? }) → { logs }
  • reticle_run({ tool: "reticle_animations", args: {} }) → running/recent animations.

reticle_capabilities

The app-advertised testable surface (registered via reticle.describe). Call this first to learn what to assert on without reading source.
  • reticle_run({ tool: "reticle_capabilities", args: { sessionId? } }) → { testids, signals, stores, flows }
reticle_session { action: "list" } also surfaces a hasCapabilities flag per session so you know when it’s worth calling. Returns empty arrays (never errors) if the app advertised nothing.

reticle_domain

Read the app’s domain model before testing: a synthesis of every saved flow + the registered capabilities. Tells you what to test and where the real risk is without crawling the app. Reads .reticle/flows/ + .reticle/contract.json, no browser needed.
  • reticle_run({ tool: "reticle_domain", args: {} }) → { flowCount, flows: [{ name, steps, grade, asserts, signals, testids, warning?, risk? }], declared: { testids, signals, stores }, coverage: { asserted, presenceOnly, assertionFree }, gaps: { unassertedFlows, declaredUntestedSignals, declaredUntestedTestids }, riskRanked, summary }
  • gaps is the point: declaredUntestedSignals are intents the app emits that no flow asserts (untested behavior); unassertedFlows act but verify no consequence. Close them with a flow + a consequence assertion (reticle_annotate).
  • riskRanked orders flow names worst-first by combining run history (.reticle/project.json: recently failed/drifted, or passed-with-errors) with assertion quality (a green assertion-free flow is still risky). Test these first. Each flow’s risk carries { level, reason, lastStatus? }.

reticle_look { action: "state" }

Read live framework/store state directly instead of inferring it from the DOM. See §17.
  • reticle_look({ action: "state", store?, ref?, path?, depth?, sessionId? }) → { stores, component? }, or { store, path, found, value, availableKeys?, storeNames } when path/depth is given.
Store reads are the reliable path. The ref component read is best-effort and bounded: when the component state can’t be read it returns component: { ok: false, reason: "component-state-unavailable" } rather than hanging. Scope big stores so you don’t pay for them. A whole store can be tens of KB. Narrow the read:
  • path extracts a dot-path sub-tree relative to the named store (numeric segments index arrays), e.g. reticle_look({ action: "state", store:"workspace", path:"captionCache.v3.0.text" }).
  • depth collapses anything deeper than N levels to a compact size marker ({…7 keys}, [Array(120)]) so you can skim a store’s shape before drilling in.
  • A wrong path returns { found:false, availableKeys:[...] }, the keys that were present where the walk stopped, so a mistyped path is self-correcting, not a bare null.

Detecting wasted re-renders (React)

A page can be thrashing, committing many React renders a second, while the DOM stays visually identical. The DOM/screenshot tools see an idle page; only a tool inside the runtime sees the commit rate. Reticle exposes it as a registered store you read with reticle_look { action: "state" }:
A render storm shows up as a commit count that climbs with no corresponding DOM mutation: a perf regression invisible to any outside-the-page tool.

reticle_session { action: "narrate" } / reticle_clock

Show the agent’s intent on the page, and control time (toasts/debounces/auto-dismiss). See §16.

reticle_run { tool: "reticle_baseline", args: { action: "save" } } / reticle_run { tool: "reticle_baseline", args: { action: "list" } } / reticle_run { tool: "reticle_baseline", args: { action: "diff" } }

Regression detection. See §8.

reticle_run { tool: "reticle_record", args: { action: "start" | "stop" | "replay" } }

Capture a flow’s reaction report and compile it into a replayable program. See §9. reticle_run { tool: "reticle_record", args: { action: "stop" } } also returns a cost:{ events, bytes } hint alongside the reaction report so you can gauge the recording’s size.

Autonomous exploration

List the interactive elements with reticle_snapshot { mode: "interactive" } and read console errors with reticle_console. See §10.

Flows, recorder & self-healing (.reticle/)

reticle_contract_save, reticle_flow_save / reticle_flow_save_recorded / reticle_run { tool: "reticle_flow", args: { action: "list" } } / reticle_run { tool: "reticle_flow", args: { action: "load" } } / reticle_flow_replay / reticle_verify { action: "flows" }, reticle_verify { action: "heal" }, reticle_annotate: record once, replay forever (anchored on testid/signal, or on an auto-derived component/source anchor when there’s no testid), with legible drift + self-heal. Full guide: Flows, the recorder & self-healing.
  • reticle_verify({ action: "flows", names?, sessionId? }) is the regression-suite call: it replays EVERY saved flow (or a subset) deterministically and returns one verdict { status, passed, failed, failures: [{ flow, verdict, whatChanged, whereInSource, nextAction }] }. Passing flows are counted; only failures carry detail. Run it after any change: one call, no LLM per flow.
  • Decision envelope: on a drift/fail, reticle_flow_replay (and each reticle_verify { action: "flows" } failure) returns the actionable fix: whatChanged, whereInSource (file:line), and a one-line nextAction (e.g. “rebind the anchor to ‘new-deploy’, or update the flow if intended”).

Human-in-the-loop control

reticle_session { action: "end" }, reticle_session { action: "resume" }, reticle_session { action: "messages" }: the human can pause the agent, send it a correction, or end the session from the floating panel; the agent receives guidance on its next tool call. Full guide: Human-in-the-loop control.

reticle_session { action: "review" }: drain the bugs the human flagged on the page

The dev clicks “Flag a bug” in the running app, points at the element that looks wrong, and types what’s wrong (⌘/Ctrl+Enter to send). Each flag becomes a mark the agent drains:
Each pending mark carries the human note, the element label, the source file:line (when the framework stamped one), and a ready-to-act fix hint. Open the file, apply the fix, then reticle_session({ action: "review", resolve: "m1" }). The human watching the panel sees ”✓ fixed: …” land. Reading never consumes a mark, so you can list → fix → verify → resolve. reticle_session { action: "list" } also reports pendingMarks so you notice flagged bugs during normal orientation.

reticle_network_mock: stub the network for error-state testing

On a page Reticle drives (reticle drive) or a leased Playwright tab (reticle_run { tool: "reticle_lease", args: { action: "acquire" } }), make a request return a 500, force it offline, or delay it, so testing error/edge states is one declared rule, no backend changes:
First matching rule wins (urlContains + optional case-insensitive method). Needs a driven or leased browser; without one it returns a recommendation pointing at reticle drive.

reticle_viewport: reproducible visual baselines (driven mode)

Pin the driven page, or a leased tab named by sessionId, to a fixed viewport so a screenshot baseline is the same on every machine:
This is one of three knobs for CI-stable visual regression. Set them together:
  1. reticle_run({ tool: "reticle_viewport", args: { width, height } }) gives the same dimensions on every machine.
  2. reticle_run({ tool: "reticle_clock", args: { freeze: true } }) stops timer and date jitter (clocks, countdowns). For animations, also pass reducedMotion: "reduce" to reticle_viewport.
  3. reticle_run({ tool: "reticle_visual_diff", args: { baseline, masks: [{ x, y, width, height }] } }) neutralizes volatile regions (clocks, avatars, ids) so only real changes fail.

4. The predicate DSL

A predicate declares what should be true. reticle_assert / reticle_assert { action: "wait" } evaluate it against the live DOM and the event buffer. This section shows the common kinds. Predicates is the full reference, including settled, compare, and count on net and signal (exact; count: 0 means never).

Leaf predicates

visible excludes elements whose boxes are fully clipped by an ancestor’s overflow:hidden or overflow:clip; a partly clipped element stays visible. It also excludes screen-reader-only text: a positioned box with a clip: rect() of no area, a clip-path: inset() whose edges meet, or a 1×1 box that clips its own overflow. That text stays present and still names its control. Expanding a clamped card can therefore prove that its previously hidden paragraph became visible. overflow:auto and overflow:scroll keep their existing visibility semantics, including content beyond the scrollport. Ordinary document content outside the window remains visible; inViewport checks the window intersection after any hidden/clip ancestor clipping, without introducing scrollport clipping. A state assertion is graded as a consequence (a wrong element or stale render cannot fake it), and is usable the same three ways anywhere predicates flow: ad-hoc (reticle_assert / reticle_act_and_wait until), as a flow step invariant (reticle_annotate { kind: "assert-state", statePath, store?, equals? }), and as a flow’s golden end-condition (reticle_annotate { kind: "success-state", statePath, … }). On a miss it names the real store value and the keys that were available: legible, not a blind fail.

Combinators

Timing

  • timeout_ms (on reticle_assert, either action): wait up to N ms for it to become true.
  • since (on net/console leaves): only consider events after this cursor (from act).
dataMatches matches field by field; a key can be a dotted path ("order.total", "items.0.id"), and * means “present, any value”.

5. Actions: full list

reticle_act({ ref, action, args }):

6. Snapshot modes & scoping

reticle_look { action: "page" } has three modes. Pick the cheapest that answers your question:
  • status (~30 tokens): the route, the title, and the open dialogs if there are any. “Where am I, is a modal open?”
  • interactive (~100 tokens): only actionable elements (buttons, inputs, tabs…). “What can I click?” Non-interactive content (e.g. 1,000 list rows) is skipped.
  • full: the whole semantic tree. Use only when you truly need everything.
scope narrows any snapshot or query to a subtree, either a CSS selector (scope: "[data-testid=item-list]") or a ref. This is the main lever for keeping payloads small and queries unambiguous on big pages. In a query or a check, a CSS selector that matches several elements searches all of them, and the result reports how many it matched in scopeMatches. A ref names one element.

7. Cookbook: real situations

Each is phrased as the situation you’re in, then how the agent verifies it.

”I told the AI to add an icon button that opens a modal"

"I changed an API call: did it fire correctly and update the UI?"

"I clicked a button and it should add an element on another page/section”

Act in section A, navigate to B, assert there:

“Data shows up only after ~30s (eventual consistency): how to refresh and see it"

"The list has 100s/1000s of rows: was my item actually added?”

Don’t scroll and eyeball. Query finds it regardless of position:
Note: if your list is virtualized (react-window/virtuoso), an off-screen row is not in the DOM at all, so reticle_look { action: "find" } correctly finds nothing. Use reticle_scroll_to to scroll the windowed container until the row renders. First get the container’s ref, for example with reticle_look({ action: "find", by: "testid", value: "item-list" }), then pass that ref: reticle_run({ tool: "reticle_scroll_to", args: { by: "text", value: "Invoice #4821", container: "e12" } }). container takes a ref, not a CSS selector. Asserting against the data with reticle.signal or reticle_look { action: "state" } also works and is cheaper.

”Login form: does it actually authorize?"

"Make sure there are NO console errors"

"A real LLM call generates a script: is it happening and rendering?"

"Upload a file → it calls an LLM → a modal shows a score"

"A button’s color should change on hover”

Pure CSS :hover styling needs a real pointer. Enable real input (§18) or use a page from reticle drive, and hover then moves the real pointer. Without it, hover fires the JS events only, and Reticle reads the computed style after the JS state change.

”Something off-DOM happened: a webhook arrived, a store changed”

Surface it from your app, then assert on it:
The agent reads this back with reticle_run({ tool: "reticle_capabilities", args: {} }); see §3.

Keeping signals from drifting (lint)

Signals only help if you actually emit one whenever user-visible state changes. The @reticlehq/eslint-plugin package ships reticle/require-signal-on-mutation, which flags any function that calls a configured store mutator but never fires the signal callee in the same body, so the signal map can’t silently fall behind the store. (The package also ships reticle/no-internal-tags and reticle/no-runaway-comment; all three are on in its recommended config.)
mutators lists the callee names that change state; signalCallee (default ['reticleSignal', 'signal']) is the name that counts as firing a signal. See adapters/lint/eslint/README.md for scoping and matching details.

8. Regression: baselines & diff

The “did anything silently break/disappear?” workflow.
diff ignores volatile ref ids and compares the semantic structure, so you get real ADDED/REMOVED elements plus the current console-error count. Great as a guardrail the agent runs after each edit: “diff against checkout-ok; fail if anything interactive was removed or console errors increased.”

Pixel-perfect visual regression that’s stable in CI (driven mode)

The semantic reticle_run { tool: "reticle_baseline", args: { action: "diff" } } above never flakes. For an actual pixel diff (reticle_screenshot + reticle_visual_diff, driven mode), three knobs make it CI-stable instead of flaky:
Without all three, a pixel diff fails on a different window size, a mid-animation frame, or a live clock/avatar: the classic reasons teams give up on screenshot tests. With them, only a real visual change fails.

9. Recording a flow

Capture everything that happens across a span. Useful for “run my whole checkout flow and tell me what happened,” or to keep a known-good trace.
reticle_run { tool: "reticle_record", args: { action: "stop" } } returns a compiled, replayable program: the agent’s reticle_act / reticle_act { steps: [...] } invocations captured during the span, with each ref normalized to its element’s data-testid where resolvable. Re-run it later:
Limitation. Normalization to a stable testid only works for elements that have a data-testid. A step whose element has none is stored in ref form (stable: false) and reticle_run { tool: "reticle_record", args: { action: "stop" } } returns a warning; replay best-effort re-uses the stored ref, which is only valid within the same live session and is not portable across reloads. Add data-testid to the elements you want replay-stable.

10. Autonomous exploration

Have the agent crawl and stress a screen without a script:
The agent then acts on each ref, observes the reaction, and reports anomalies (failed requests, console errors, dead controls). Good for “click everything on this page and tell me what breaks.”

11. Turning your test cases into agent checks

If you already have test cases (a QA checklist, acceptance criteria, a spreadsheet, manual steps), you can hand them to your agent and have it run + verify each against the live app. Each case becomes a predicate: A practical workflow:
“Here are our 12 dashboard test cases. For each, drive the app with Reticle and tell me pass/fail with evidence. For any failure, show the source file to fix.”
This is where Reticle helps most: the agent runs the manual cases you never automated in seconds, on every change. It complements your CI Playwright/Cypress suite (which gates releases); Reticle is the in-loop checklist while you build.

12. Token discipline

Reticle is cheap by design (benchmark), but keep it that way:
  • Prefer reticle_look { action: "find" } + reticle_assert (~30 tokens each) over snapshots inside the loop.
  • Use mode: "interactive" or "status", not "full".
  • Use scope to look at just the relevant subtree.
  • Reach for mode: "full" only when you truly need the whole page.

13. Best practices & gotchas

  • Accessibility = legibility. Real roles, labels, and data-testids make queries precise and stable. It’s also just good a11y.
  • Stable handles for controls. Prefer data-testid over names that include dynamic counts (e.g. “Notifications (3)”); the count changes the accessible name.
  • Always thread since. Pass the cursor from reticle_act into observe/assert so you only consider what happened after the action.
  • Use timeout_ms for async. Don’t assert instantly on something that arrives over the network or after a re-render.
  • Watch session.throttled. Background tabs throttle timers/rAF/pointer gestures, so an act can silently no-op. Every reticle_act / reticle_assert / reticle_act_and_wait result carries session: { lastSeenMs, throttled, focused } and, when throttled, a warning. Refocus the tab (or run it foregrounded) before driving; pass refuseWhenThrottled: true to hard-fail instead.
  • ready is connection, not hydration. A session or lease answering ready: true has an SDK attached and answering; it does not mean the app finished hydrating. A slow-hydrating page (SvelteKit, Next) can still be mid-render, with controls not yet wired up, so an act issued immediately can miss. If reads come back empty (a find for buttons returns 0 matches and there are no console lines), that may mean not hydrated. Make the first act reticle_act_and_wait with an until on something hydration renders, or reticle_assert { action: "wait" }.
  • Scope big pages. On dashboards with hundreds of elements, scope queries to the panel you care about.
  • Never breaks your app. Observers are additive and reversible (reticle.disconnect() restores patched globals). It won’t interfere with your app’s behavior.

14. FAQ

Does this run in production?

Not by default. Keep reticle.connect() behind a development guard. The SDK tree-shakes out of production builds. To let an agent drive a staging or production site, install the production-mode loader. It loads nothing until a signed activation ticket is in the URL. See Production mode.

Do I have to change my components?

No, for basic look/act/observe. You’ll get better results by adding data-testids and labels where the agent needs precision.

Does it work without React?

The core (DOM/network/route/console/animation/snapshot/actions) is framework-agnostic and is gated against a vanilla-TS app. What is proven for each framework, from driven to a verdict in CI to install-gated to wired but unverified, is listed once in Frameworks. See also what Svelte support is and is not.

Can it judge whether my UI looks good?

No. Reticle verifies behavior, not aesthetics. Visual/pixel correctness and “does it feel right” remain human (or a visual-diff tool).

Does it replace Playwright/Cypress?

No. Those are your scripted CI suite. Reticle is for in-loop verification while the agent codes, and for the cases you never automated. They compose.

How does it compare to Playwright MCP / Chrome DevTools MCP?

Those let an agent drive/ inspect a separate browser; Reticle verifies your own running app (real session/auth) with assertions + regression as first-class, far more cheaply. See Reticle vs Playwright MCP and Reticle vs Chrome DevTools MCP.

Multiple tabs/apps?

Each tab is a session. Omit sessionId, and Reticle picks the active tab of your project. It refuses when the choice is ambiguous. Then pass a sessionId from reticle_session { action: "list" }.

15. Security & privacy

  • Dev-only, localhost-only by default. The bridge binds 127.0.0.1; the SDK is meant for dev builds.
  • No app data leaves your machine until you link a project. Nothing from your app leaves the machine until you link a project (reticle connect, or RETICLE_API_KEY in CI). After that, what syncs is set by reticle config, and when the Harness (Reticle’s model-driven explorer, which runs on the Reticle platform) drives, the platform sees the steps it drives. Secret values never leave. Baselines/recordings are local. The CLI sends opt-out usage metrics: a random install id, event names and counts, and no code, no name and no IP address. Two fields can connect events to an organization: the workspace id of a linked project, and the licence id of a licensed build (see telemetry). Opt out with reticle telemetry disable, RETICLE_TELEMETRY=0 or DO_NOT_TRACK=1. Feedback that you or your agent send on purpose (reticle feedback / reticle_session { action: "feedback" }) is the only free text that leaves the machine. It is never sent passively, it is redacted first, and RETICLE_FEEDBACK=0 turns it off. A reply address goes with it only when you give one with --contact.
  • Network bodies aren’t captured by default: only method/url/status/timing. Body capture is opt-in and runs through a redactor (drop password/token/secret/… + your patterns).
  • Additive & reversible. Reticle patches fetch/History/console defensively and restores them on disconnect; it will not break the app under test.

Extending the redaction rules

The built-in rule catches the credential names that are common across apps. Yours has its own vocabulary in both directions (a licenceKey it has never heard of, and a designToken it redacts by mistake), so connect() takes a redact option:
  • keys adds to the rule. A string matches a key name exactly, case-insensitively, so 'code' does not redact codeOwner. A RegExp is tested against the key.
  • allow exempts a key from the default rule. It loses to keys: an explicit redact instruction beats an exemption. Exempting a key the default rule considers a credential prints a one-time warning naming it, because that value now reaches the agent transcript and the on-disk journal in cleartext.
  • There is no way to replace the default set. Both options are additive on purpose: a config that could turn the whole rule off would eventually ship in an app that leaks, and Reticle would be the thing that recorded it.
  • With no redact option, behaviour is exactly what it was before this option existed, pinned by a test that walks every credential name and every known false positive.
What crosses the bridge, and why it matters. Most captures pass through the SDK in your page. Request bodies and response headers on the driven path (reticle drive, or a CDP-attached browser) do not: the daemon reads them straight from the network stack. So the literal strings in keys are announced to the daemon when your app connects, and it redacts them there too. Two parts deliberately stay in the page: A RegExp does not travel because compiling a pattern that arrived over a socket and running it against every key of every request body is a denial-of-service surface. allow does not travel because it is the only part of the config that removes redaction, and the driven path keeps the built-in floor rather than letting a page lower it. Both exclusions fail in the safe direction: the driven path can over-redact relative to your config, never under. If a key must be redacted everywhere, name it as a plain string.

16. Presenter mode, narration & fake clock (watch + control)

Presenter mode: let a human watch the agent

Turn it on when connecting:
You get, in the page itself:
  • a glowing border while the agent is working,
  • a synthetic cursor that flies to each target before acting,
  • click ripples, hover rings, and a status HUD (“Clicking button “Save”… ✓ passed”),
  • a per-action pacing delay (pace, ms) so a human can follow.
All presenter DOM uses data-reticle-* and is excluded from snapshots/observers, so it never pollutes what the agent sees. Use setIgnoreSelectors([...]) to also hide your own dev widgets.

Session liveness: the HUD never gets stuck “running”

A session starts on the agent’s first activity and must reliably end even when the agent misbehaves. Reticle is an MCP tool, so the agent can crash, disconnect, or forget to hand control back. The agent must call reticle_session { action: "yield" } before it stops. Also, the browser throttles the timers of a background tab. So the Node server owns liveness, not the browser tab:
  • Agent goes idle or forgets to yield → a server-side timer (not affected by tab throttling) ends the session after idleEndMs (default 5 minutes) with no agent command. It shows the human that the agent is WAITING (your turn), and pushes that to the browser. A backgrounded tab still receives that push, so you can switch windows and come back to a correctly-ended HUD.
  • Agent (MCP client) disconnects cleanly → every active session ends at once.
  • Agent kills the Reticle server process (so no push can arrive) → the SDK self-ends the session after it can’t reach the bridge for BRIDGE_LOST_MS (~15s), showing “lost connection to Reticle.”
  • Slow-but-alive agent → if it goes quiet long enough to auto-end and then acts again, the session revives automatically (an explicit reticle_session { action: "end" } stays terminal).
Tune the idle window with reticle_session({ action: "tune", idleEndMs }); it updates both the browser timer and the server reaper. When the HUD covers a control you have to test, reticle_session({ action: "tune", hud: "top-left" }) moves it (any corner), and hud: "hidden", "shown" or "removed" (until the page reloads) take it out of the way; the call fails rather than pretending if the page could not apply it. The human keeps the panel (with Copy/Export of the run) after any end.

reticle_session { action: "narrate" }: show the agent’s intent

So the human sees what the agent is about to do and why:
It renders on the HUD. (The agent’s private reasoning isn’t visible to Reticle; narration is how it surfaces intent on the page.)

reticle_clock: control time deterministically

Fast-forward toasts, debounces, auto-dismiss, and commit-on-blur without waiting:
It does not freeze requestAnimationFrame/microtasks (React’s scheduler keeps running), and Reticle’s own internal timers are insulated, so freezing never stalls the tools.

Action refinements (from real-app use)

  • blur now fires a bubbling focusout, so React’s commit-on-blur (onBlur) runs, and inline editors and form fields commit. fill/type focus first so a later blur commits.
  • hover accepts { holdMs } to dwell, so timer-gated reveals mount; then reticle_assert { action: "wait" } for the revealed nodes.
  • drag yields a frame between phases (React flushes between steps) and accepts { data: { mime, value } } for custom dataTransfer payloads.

Richer dataMatches (signals)

On a failed signal assert, the result includes a near-miss: the signals that did fire with that name + their data. And reticle_observe’s summary now includes domChanged (in-place text/attribute re-renders, not just added/removed nodes).

17. Evidence-of-effect, act+await, state, capabilities, replay

These close the “is the action trusted?” gap, so you can tell my action missed vs the app didn’t react vs the tool didn’t dispatch.

reticle_act returns evidence-of-effect

Every reticle_act result now carries an effect:
settled:false, settleReason:"timeout" means the settle frame did not flush within the budget (a throttled/background tab). This is not a failure: the dispatch landed and the tool resolved. Read it to disambiguate failures instantly: targetMatched:false = your ref was stale; defaultPrevented:true = a handler cancelled it; domMutatedWithin:0 + valueChanged:false = the app didn’t react.

reticle_act_and_wait: one hop for act → observe → assert

Performs the action (with settle so React commits land in the window), waits for until, and returns the action’s effect + the verdict + the full causal trace. Collapses four calls into one.

reticle_look { action: "state" }: read live framework/store state

No need to broadcast a signal for every fact. Register stores in your app:
Which state libraries work. registerStore accepts anything shaped { getState, subscribe }, so zustand and Redux (and Redux Toolkit) need no adapter at all: pass the store. For everything else Reticle ships adapters, because the shape is the only thing missing:
Svelte and Pinia are the two whose adapters do something you would not guess from the shape. A Svelte store has no pull side at all: { subscribe } is the entire contract, no getState. svelteStore reads the current value by subscribing, catching the synchronous first callback the store contract guarantees, and unsubscribing immediately (the same thing svelte/store’s own get() does), so it holds no lasting subscription and needs no teardown. It also swallows that first callback on subscribe, because forwarding it would emit a state change at registration time for a change that never happened. piniaStore subscribes with detached: true and flush: 'sync'. Without detached, a store registered from inside a component goes permanently silent after that component unmounts: still readable, but never emitting another state change, which reads exactly like an app that stopped changing. Without sync, the notification lands a Vue tick late, outside the window that links a state change to the click that caused it. Note that $state carries state, not getters: a Pinia getter is derived, so asserting on the state it derives from is the stronger assertion anyway. Recoil has no enumerable registry of live atoms and no per-atom subscription outside React, so it takes an atom map (like Jotai) plus the transaction stream from a small bridge component:
Each atom comes back as { status, value, error } rather than a bare value, because Recoil atoms can be async: calling getValue() on a pending selector throws the pending promise, which would lose the whole state read over one slow atom. A loading atom reports status: 'loading' instead of silently reading as empty. TanStack Query is worth registering even if you register nothing else. Its cache holds the state most likely to be wrong in a way nothing else can observe: a stale value served as fresh, a mutation that never invalidated its query, an optimistic update never rolled back. None of those fire a network request, so a network log shows silence and the DOM shows a plausible number. The cache is the only witness. The adapter exposes status, fetchStatus, isStale, isInvalidated and dataUpdatedAt per query key (isInvalidated: true after invalidateQueries marks the query), so an agent can assert the stronger property: not “the number rendered is 42” but “the number rendered came from fresh data”. React Context / useState / useReducer have no store object to adapt: the value lives in the fiber tree and the only subscription is a re-render. Invert it with the hook:
Store reads are the reliable path; ref reads degrade to a structured failure rather than blocking. path (dot-path, numeric segments index arrays) and depth keep a 60KB store from becoming a token tax, and a wrong path returns the keys that were there, so it’s self-correcting.

Charts and dashboards: geometry faults report themselves

Every dashboard widget except one renders text a comparison can read: a KPI card renders a number, a table renders rows. A chart renders geometry (the data has been through a scale function into coordinates), and a perfectly correct series in the store can still become a blank, flat, or NaN-filled path. Neither a state read nor a screenshot-vs-baseline catches that. So any element descriptor containing faulty plot geometry carries a chart field. There is no extra tool call and no flag: query the chart the way you already would, and a broken one tells you.
kind is one of non-finite-coordinates (a zero-range scale divided by zero, always a bug), empty-geometry (the chart mounted but no data reached it), or degenerate-geometry (every point identical). A healthy chart adds no field at all, so this costs nothing on the common path. A genuinely flat line (constant data) is not flagged. Canvas charts (Chart.js, ECharts) are pixels, not DOM, so nothing above applies. Read their data directly instead, which is the only route short of vision:

reticle_capabilities: the app’s testable surface

Declare it once so the agent learns the surface without reading source:

reticle_record { action: "replay" }: recordings become re-runnable programs

reticle_run { tool: "reticle_record", args: { action: "start" } } → drive the flow → reticle_run { tool: "reticle_record", args: { action: "stop" } } returns a compiled program (steps bound to testids/signals, not volatile refs). reticle_run({ tool: "reticle_record", args: { action: "replay", recordingName } }) re-executes it, so your flow becomes a deterministic regression run, not a checklist.

18. Real input mode: native hover & drag

Reticle drives actions by dispatching JS events from inside the page. That covers click, fill, type, select, submit, press, and HTML5 drag, but it cannot trigger browser-native pointer behavior: onMouseEnter/onMouseLeave, hover-gated reveals, and pointer-library drags rely on the browser’s real hit-testing, which synthetic events don’t drive. Clicks are synthetic by default, on purpose. Even with real input configured, click/dblclick run the occlusion-honest synthetic path (full pointerdown→…→click sequence + an occluded hit-test + off-viewport auto-scroll), reporting inputModeReason:"synthetic-click-preferred". There’s no coordinate gesture for the presenter HUD to intercept or to miss off-screen, and synthetic dispatch reaches the resolved element directly. Reserve native clicks for the rare isTrusted-gated case (native file picker, clipboard) with args:{ native:true }. Real input remains the path for hover/drag, which genuinely need the browser’s hit-testing. Every reticle_act result tells you which path ran:
When inputMode is "synthetic" and the target has hover/enter handlers, the result carries a warning so you know a hover may be a no-op. You never have to reverse-engineer it. inputModeReason is never a silent fallback. When real input is configured but a pointer act still ran synthetic, the result says why, so per-element inconsistency is diagnosable instead of mysterious: (When real input is not configured, an act sets no inputModeReason, because synthetic is the default. The exception is an explicit native: true, which gets real-input-not-configured-for-this-session.)

Enable real input (optional, opt-in)

Point Reticle’s server at a Chrome DevTools (CDP) endpoint; it then drives real pointer input (via Playwright connectOverCDP) at the element’s box for hover/drag (and for click/dblclick only when you pass args:{ native:true }, since clicks default to synthetic), and reports inputMode: "real".
  1. Launch your browser with remote debugging:
  2. Tell the Reticle server where it is, via the MCP config env:
That’s it. Reticle correlates the CDP page to your SDK session by URL; pointer actions now fire native hover/enter so hover-gated suggestion panels, tooltips, and pointer-based drag become drivable. Everything else is unchanged, and with no RETICLE_CDP_URL set, Reticle stays in the synthetic (zero-dependency, in-page) mode. Playwright is an optional dependency loaded only when you opt in.
SPA navigation is handled. The URL correlation tracks client-side route changes (pushState/replaceState/popstate), so real input keeps working after your app navigates into a sub-route, e.g. the hover/quick-edit cluster on a /workspace view stays drivable. (If you see inputModeReason:"page-not-correlated-to-a-cdp-target", no CDP target matches the session URL, and the act ran synthetic. The field tells you so.)
Watching the agent (presenter). With present: true the activity border now glows once while the agent is busy and fades when idle (no per-action strobe); the HUD docks bottom-right by default, shows a READING vs ACTING chip so you can tell observation from action at a glance, and reticle_session { action: "narrate" } lines are queued with a minimum on-screen dwell so none flash by unread.
Limitation: un-scriptable tabs. Reticle observes/drives a tab through the in-page SDK + (optionally) CDP; it cannot bring to front or recover a browser tab the OS won’t let it script (e.g. a backgrounded or non-default-browser tab reporting hidden:true/throttled:true). When that happens, reticle_session { action: "list" } and every act/assert result carry a session.recommendation saying so. For a hidden tab it points to reticle_run { tool: "reticle_lease", args: { action: "acquire", url } } (or reticle drive <url> for a human) for a guaranteed scriptable context. Refocus the tab, or lease one.
Last modified on November 10, 2026