# Agent cheatsheet Source: https://reticle.mintlify.app/agent-cheatsheet # Reticle — agent cheat-sheet One screen to get fluent. Reticle is the **proof layer for AI agents** — no screenshots, no vision model, evidence not prose. Everything below returns structured data. Full guide: [usage.md](usage.md). ## The core loop: look → act → observe → assert | Verb | Tool | One-liner | | ----------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | **look** | `reticle_snapshot` / `reticle_query` | See the page (semantic tree) / find one specific element. | | **act** | **`reticle_act_and_wait`** / `reticle_act_sequence` / `reticle_act` | **Act + name the consequence, one hop — reach for this first.** / batch a whole journey in one hop / move the app and prove nothing. | | **observe** | `reticle_observe` / `reticle_wait_for` | Everything the app did after `since` / block until true. | | **assert** | `reticle_assert` | Evaluate a predicate → `{ pass, evidence, failureReason? }`. The end of every loop. | > **Only `reticle_act_and_wait` and `reticle_assert` produce a verdict.** Everything else moves or reads the app and proves nothing, so a drive ending without one of those two has no result however many tools it used. `reticle_act` is the tool agents reach for by habit and it catches nothing; `act_and_wait` is where defects actually surface. And `verified: "unknown"` is not a pass — it means Reticle drove the app and could not tell what happened. Report it as unknown. `reticle_act` returns a `since` cursor — pass it to `reticle_observe({ since })` to scope the window. Elements are addressed by stable refs (`e7`) from `snapshot`/`query`; they re-resolve across re-renders. **`assert`/`wait_for` are auto-scoped to your last act.** By default they only count events buffered *since* the most recent act, so a stale signal from a previous step can't fake a pass — pass an explicit `since` to override. **Clicks run the code, not pixels:** `reticle_act` click fires the full pointer sequence on the element (no coordinate gesture for the HUD to intercept), reports `occluded:true` when something covers the target, and stays synthetic even with CDP configured (use `args:{ native:true }` for a trusted native click). **Never sleep — wait deterministically.** Fixed sleeps are the #1 cause of flaky agent tests. Instead: * `reticle_act_and_wait({ ref, action })` with **no `until`** waits for the page to *settle* (network + structural DOM idle; ambient count-up/spinner churn is ignored so an animated page still settles) before returning — the one-call replacement for "click then sleep 500ms". * Need to wait without acting? `reticle_wait_for({ predicate: { kind: "settled", quietMs } })`. * Waiting for a specific outcome? Pass that consequence as the predicate (`{ signal }` / `{ net }`), or `allOf` it with `{ kind: "settled" }` to wait for both the event *and* the page going quiet. **A predicate that does not parse produces NO verdict — not a failing one.** Nothing runs, so the drive ends with no result at all, which is strictly worse than a failure. The shapes below are the ones agents reach for most; the first column now works, but knowing the second saves you the round trip: | You may write | It means | | --------------------------------------------------- | -------------------------------------------- | | `{ kind: "text", text: "Saved" }` | `{ kind: "text", contains: "Saved" }` | | `{ kind: "element", role: "button", text: "Save" }` | `{ kind: "element", query: { role, text } }` | | `{ kind: "route", url: "/checkout" }` | `{ kind: "route", contains: "/checkout" }` | If a predicate is still rejected, the error names the fields **that kind** accepts — read it rather than guessing again, and note `state` spells its selector `path` while `route` spells its `pathname`. **Assert a consequence, not just presence.** `{ signal }` / `{ net }` prove the feature actually did something; `{ element }` / `{ text }` only prove something is on screen — which a stale render or a locator healed to the wrong element can fake. A *passing* presence-only `reticle_assert` returns `advice` nudging you to a consequence; heed it on anything that matters. ## The 4-layer cross-check — never trust a green the state contradicts A claim is real only when the layers agree. Check more than the UI: | Layer | Tool(s) | Question it answers | | ----------- | ------------------------------------------ | ------------------------------------------- | | **UI** | `reticle_snapshot` / `reticle_query` | Is it on screen / in the right state? | | **signal** | `reticle_capabilities` / `reticle_observe` | Did the app emit the intent it advertised? | | **network** | `reticle_network` | Did `POST /x` actually fire and return 200? | | **store** | `reticle_state` | Does live framework/store state match? | > **Rule:** a passing UI assert that the store, network, or signal contradicts is a **false green**. **Session health is universal.** Every live-session tool result carries a `session` block (`throttled`, `focused`, `lastSeenMs`); when `throttled:true` it also adds a `warning` + `recommendation` (refocus, or `reticle drive`). A throttled/backgrounded tab can silently no-op timers/rAF/pointer gestures — if you see `session.throttled`, distrust a green and refocus first. > Store reads (`reticle_state`) are the reliable path; the DOM can lie (optimistic UI, stale render). **Truncation and coverage are declared, never silent.** A big `reticle_state` read carries `truncation` when the transport caps dropped items — its presence means "this is NOT the whole value", so an absence assertion over a truncated read proves nothing; scope with `path`/`depth` instead. A `reticle_assert` verdict carries `coverage` when part of the page was unobservable (cross-origin iframe, closed shadow root) — a green then means "nothing failed in the part I could see", not "the page is correct". Both fields are **omitted when everything was fine**, so their presence is the warning. **Charts report their own geometry faults.** Any element descriptor containing a broken plot carries `chart: [{ kind, tag, attr, sample }]` — `non-finite-coordinates` (a zero-range scale divided by zero; always a bug), `empty-geometry` (mounted but no data reached it), `degenerate-geometry` (every point identical). No extra call and no flag: query the chart as you would anyway. A healthy chart adds nothing. This matters because a chart is the one widget whose correctness is *geometry* — the store can be right while the polyline is blank, and neither a state read nor a screenshot catches it. Canvas charts are pixels, not DOM: read their data with `canvasChartData(canvasEl, window)` instead. **Reads never go silently empty.** A zero-result read returns a `hint`, not a bare `[]`: `reticle_query` → `{ route, presentTestids, knownEmptyState }`; `reticle_network` → `{ totalInWindow, present[] }` (what DID fire); `reticle_console` → `{ totalInWindow, byLevel }` (so "0 errors" ≠ "silent page"); `reticle_state` lists `storeNames` when a store isn't found. Read the hint before assuming "not there." Scope big stores with `reticle_state({ store, path:"a.b.0", depth })` instead of paying for the whole tree; a wrong `path` returns `{ found:false, availableKeys }` so it's self-correcting. ## Core tool set The tools advertised DIRECTLY — what you'll use 90% of the time. Everything else is one `reticle_run` away (see Token note below). `reticle_sessions` · `reticle_navigate` · `reticle_snapshot` · `reticle_query` · `reticle_act` · `reticle_act_sequence` · `reticle_act_and_wait` · `reticle_observe` · `reticle_network` · `reticle_console` · `reticle_wait_for` · `reticle_assert` · `reticle_state` · `reticle_inspect` (DOM node → `src/App.tsx:104`) · `reticle_feedback` (tell the maintainers what is missing) · `reticle_session` (hand back: `{action:"yield"}` the moment you stop driving, `{action:"resume"}` after a human pause). Frequently useful but NOT core, so reach them through `reticle_run({ tool, args })`: `reticle_domain` (learn the app + gaps), `reticle_capabilities` (the app's declared testable surface), `reticle_baseline {action:"diff"}`, `reticle_project` (run history). **Reach past core when…** you need to record/replay a journey (`reticle_record {action:"start"}/stop`, `reticle_replay`), persist a self-healing golden flow (`reticle_flow_save*` / `reticle_flow_replay` / `reticle_flow_heal`), compile annotations (`reticle_annotate`), explore autonomously (`reticle_explore` lists controls; `reticle_crawl` clicks them all and reports anomalies — **destructive**), reveal a virtualized off-screen row (`reticle_scroll_to` — when `reticle_query` finds nothing because a windowed list hasn't rendered it yet), visual-check (`reticle_screenshot` / `reticle_visual_diff`, pinned with `reticle_viewport` for reproducible baselines), test error/edge states by stubbing the network (`reticle_network_mock` — 500 / offline / delay, driven mode), control time for toasts/debounces/auto-dismiss (`reticle_clock { freeze | advanceMs | reset }`), or work with a human (`reticle_session {action:"end"}` / `reticle_session {action:"resume"}` / `reticle_session {action:"messages"}`, and **`reticle_session {action:"review"}`** to drain + fix the bugs the human flagged from the panel). ## flows vs baselines vs project.json (the persistence layers) | Artifact | Tool(s) | What it is | | ---------------- | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | | **flows** | `reticle_flow_save*` / `reticle_flow_replay` / `reticle_flow_heal` | Replayable **golden journeys**, anchored to testids/signals — drift is legible and self-heals. | | **baselines** | `reticle_baseline {action:"save"}` / `reticle_baseline {action:"diff"}` | Structural **"before" snapshots**; `reticle_baseline {action:"diff"}` flags regressions against them. | | **project.json** | `reticle_project` | Cross-run **run-history** — "did it behave like last run?" read via `reticle_project`. | > `reticle_project` / `project.json` are the **run-history layer**. flows answer "does the journey still work?"; baselines answer "did the structure change?"; project.json answers "is this run consistent with prior runs?". **Visual layer (opt-in).** `reticle_screenshot` saves a PNG baseline to `.reticle/visual/.png`; `reticle_visual_diff` perceptually compares the live page to it (`{ masks }` to ignore volatile regions, `{ maxRatio }` tolerance) → `{ matched, changedPixels, ratio, region, diffPath }`. It answers "does it **look** right" — complementary to the behavioral layers, never a replacement. Both need a **driven browser** (`reticle drive ` / `RETICLE_CDP_URL`); without one they return `{ ok:false, reason:"no-visual-provider" }` (the always-on SDK ships no screenshotter). ## Start here 0. Just ran `reticle init` / started the dev server? Poll `reticle_sessions()` until your tab appears — readiness is server-internal now, so the first live call already blocks until the SDK connects. 1. `reticle_sessions` — find the connected tab (omit `sessionId` if there's only one). **An empty list is not a dead end: read the `why` field.** It names which case this is — no app running, an app running that has never dialled this daemon, a project that never went through `init`, or a tab that closed — and the fix for each. Do not fall back to static reasoning until you have read it. 2. `reticle_domain` — learn the app BEFORE testing: the saved flows, what each asserts, and the **gaps** (declared signals/testids that no flow verifies — untested intent). Tells you what to test and where the real risk is without crawling the whole app. Falls back to `reticle_capabilities` for the raw testable surface (`testids`, `signals`, `stores`, `flows`). 3. Run the loop: **look → act → observe → assert**, cross-checking the 4 layers on anything that matters. ## Token note * **Keep observation cheap.** Prefer `reticle_query` / scoped or `interactive` `reticle_snapshot` / `reticle_assert` over dumping the full tree. A full verify loop is \~100 tokens; see [token-efficiency.md](token-efficiency.md) (\~73× leaner than full-tree snapshots). * **Re-look with `reticle_snapshot({ diff:true })`** after an action — it returns only what changed (`mode:delta`/`unchanged`), \~99% fewer tokens than a full re-snapshot and no stale tree to mis-read. Every snapshot/query result carries `cost:{ bytes, tokens }` — re-scope before reading if it's large. * **Cap broad reads.** `reticle_query` takes `limit` (caps descriptors; reports `total`/`truncated`) and `count_only` (just the match count). `reticle_network` / `reticle_console` take `limit` (most-recent-N, reports `droppedOldest`) and carry the same `cost` hint — so a busy page or wide window never floods your context unnoticed. * **A saved flow tells you if it's a real test.** `reticle_flow_save` returns `assertions.grade` (`asserted` / `presence-only` / `assertion-free`); if it's not `asserted`, add a consequence (`reticle_annotate` assert-signal/assert-net or a success-state) so it can't pass while broken. On replay, an ambiguous heal (two testids tie) is surfaced, never auto-applied — and an `apply` heal re-replays the rebound flow and **refuses to write** if the success consequence no longer fires (`status:consequence_broken`): it heals the locator, never the intent. * **Predicate schema is not bloated.** The recursive predicate DSL used by `reticle_assert` / `reticle_wait_for` / `reticle_act_and_wait` is **factored, not inlined**: when converted to the JSON Schema MCP sends, the predicate body is emitted **once** (\~2.7k chars ≈ **\~685 tokens** per tool) and recursion is handled by self-`$ref` (`#/properties/predicate`) — no per-recursion duplication. No action needed. * **One tool surface.** Reticle advertises the core set directly (sessions/navigate/snapshot/query/act/act\_and\_wait/observe/network/console/wait\_for/assert/state/inspect/feedback/session — the whole detect loop, plus the file-pointer, the feedback channel and the handback) PLUS two meta-tools that keep every other tool one call away: `reticle_tools` (discover — no args lists every tool name + summary; `names:[…]` loads full params on demand) and `reticle_run({ tool, args })` (invoke any tool by name; a top-level `sessionId` is forwarded to the target, so you need not nest it in `args`). So to record/replay/verify a flow, call `reticle_run({ tool:"reticle_flow_verify", sessionId })` (or `reticle_tools` first to see params). There is nothing to pick. `RETICLE_ADVERTISE_ALL_TOOLS=1` (read by the daemon at startup, so restart it) advertises everything with output schemas — a verification switch for suites, not a way to run agents. **Sizes are deliberately not quoted here** — a count in prose goes stale and has three times already. `reticle_tools` reports the live surface, and SKILL.md carries the one gated table. # Architecture Source: https://reticle.mintlify.app/architecture # Reticle architecture — how it works and why it's built this way > For engineers evaluating Reticle, integrating it at scale, or contributing. It explains the moving parts, the data flow, and the design decisions behind them. If you just want to get running, start with [getting-started.md](getting-started.md); come back here when you want to know *why*. *** ## The one-paragraph model Your app, in dev, embeds a tiny **SDK** that instruments the page (DOM, network, console, routing, framework state) and opens a WebSocket to a local **bridge**. The bridge runs inside the Reticle **server**, which also exposes an **MCP server** — the standard protocol AI agents speak. Your coding agent calls MCP tools (`reticle_query`, `reticle_act`, `reticle_assert`, …); the server turns them into commands over the WebSocket; the SDK executes them in the page and streams back structured events. The agent thus **looks, acts, observes, and asserts** on the real running app — never on a screenshot. ``` ┌─────────────────┐ MCP (stdio/SSE) ┌──────────────────────────┐ WebSocket ┌────────────────────┐ │ AI agent │ ──────────────────────► │ @reticlehq/server │ ◄────────────────► │ @reticlehq/browser│ │ (Claude Code, │ reticle_query/act/... │ bridge + MCP + CLI │ commands/events │ (SDK in your app) │ │ Cursor, ...) │ ◄────────────────────── │ (Node) │ │ (the DOM) │ └─────────────────┘ tool results └──────────────────────────┘ └────────────────────┘ │ reads/writes .reticle/ (flows, baselines, runs, contract) ``` *** ## The packages (and the boundaries between them) Reticle is a pnpm + Turborepo monorepo. The split is not cosmetic — each boundary enforces a rule. | Package | Runs in | Responsibility | Hard rule | | ---------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------ | | `@reticlehq/core` | both | The **wire contract**: every constant + zod schema crossing any boundary | Depends only on `zod` | | `@reticlehq/browser` | the browser | Instrument the page; execute commands; emit events | Never imports Node APIs | | `@reticlehq/server` | Node | The bridge, the MCP server, the `reticle` CLI, flow/run storage | Never imports DOM APIs | | `@reticlehq/react` | the browser | The SDK **kit** you install in a browser app: re-exports the browser sensor (so one install gives both `reticle` and `install`) and maps a DOM node → React component → source `file:line` | Core works without the source-mapping half | | `@reticlehq/babel-plugin`, `@reticlehq/next`, `@reticlehq/vite-plugin` | build time | Stamp `data-reticle-source` for source mapping (and, for Vite, inject `connect()`) | Plain tooling | > Note: pre-2.0, `@reticlehq/core` was a single umbrella package that re-exported all of the above under subpaths. In 2.0 the umbrella was retired and `@reticlehq/core` became the bottom-of-graph wire contract. **Why core-as-contract matters:** because the browser and the server are two different runtimes (a DOM and a Node process) that must agree exactly on every message, the temptation is to inline a string like `"net.request"` in both. That's how drift and silent breakage start. Instead, every such string and shape lives once in `@reticlehq/core` as a named constant + a zod schema. The browser and server both import it; neither can invent a message the other doesn't understand. The server zod-parses **every** inbound WebSocket message — malformed input closes the socket rather than flowing into logic. *** ## The data flow, end to end 1. **Connect.** In dev, your app calls `reticle.connect({ session })`. The SDK opens a WebSocket to the bridge (`ws://localhost:4400/reticle` by default) and sends a `HELLO` carrying the session id, protocol version, and (if configured) a pairing token. Each browser tab uses `SESSION_AUTO` — a unique id — so multiple apps/tabs never collide. 2. **Capture.** The SDK installs observers: a `MutationObserver` for DOM changes, wrappers around `fetch`/`XHR` for network, a console hook, a history hook for routing, and registries the app opts into (`registerStore`, `registerCapabilities`, `reticle.signal`). Events flow into a bounded **ring buffer** — recent history is always available, memory is capped. 3. **Look / act.** The agent calls an MCP tool. `reticle_query` finds an element by role/text/testid/ component and returns a stable **ref**. `reticle_act` dispatches an action against that ref and returns an **effect** report (did it land, did the DOM mutate, did focus move…). The server sends the command over the WebSocket; the SDK runs it and replies. 4. **Observe.** After an action, the agent reads what happened — `reticle_network`, `reticle_console`, `reticle_state`, or the reaction digest from `reticle_act_and_wait`. The server pulls the relevant slice of the ring buffer (scoped to a cursor so stale events can't leak in) and returns a compact summary. 5. **Assert.** `reticle_assert` (and a flow's declared `success`) evaluates a **predicate** over program truth — a network call that returned 200, a store value, a `signal` the app emitted — not just "an element exists." This is the difference between "looks done" and "is done." *** ## The four design decisions that define Reticle ### 1. Assert the consequence, not the appearance Most agent-browser tools can confirm "an element matching X is present." That's the weakest possible oracle: a wrong or healed-to-wrong element satisfies it, and the regression ships anyway. Reticle grades evidence in tiers: * **Tier 1 — an app signal** (`reticle.signal('order:placed')`): the strongest, because a wrong element can't fake it. Available when the app emits it (a \~30-second opt-in). * **Tier 2 — network + route + state**: a POST returned 200, the URL changed, a store value updated. Strong, and works on most SPAs with no setup. * **Tier 3 — DOM/text presence**: the weak fallback Reticle nudges *away* from. Reticle is honest about which tier a given assertion used, so "green" carries its own confidence. ### 2. Structured reads, not screenshots A screenshot is \~1,365 image tokens per look, slow, non-deterministic, and **blind to everything non-visual** — the failed request, the console error, the route that didn't change. Reticle reads the accessibility tree, the network log, the console, and framework state as compact structured data. That's an order of magnitude cheaper *and* it sees the bugs pixels can't. See [benchmarks.md](benchmarks.md) for the measured comparison. ### 3. Record once, replay deterministically The same verification runs over and over — every commit, every CI run. Reticle records a **flow** once, then replays it with **no AI model**: it re-resolves each element's durable anchor against the live DOM and re-asserts the declared consequence. A CI gate diffs the verdict exactly, at \~0% flake, for a couple hundred tokens — versus an agent re-driving the whole flow with the model every time. Self- healing rebinds a drifted anchor **only if the consequence still fires**, so it never "heals to the wrong element and ships the regression." ### 4. Dev-only, localhost-only, your app data stays local The SDK is tree-shaken out of production builds and connects only to a local bridge. The bridge binds to loopback by default; exposing it beyond localhost *requires* a pairing token (the server refuses to bind a non-loopback host without one). Every environment variable that gates a security control is a single named constant, so a typo can't silently disable auth. Nothing from the app under test — no DOM, network, console, state, or source — ever leaves your machine. The CLI reports anonymous, opt-out usage metrics only (a random id + event names like `invoke`/`session_start`; no code, no PII — see [telemetry](telemetry.md)); opt out with `reticle telemetry disable`, `RETICLE_TELEMETRY=0`, or `DO_NOT_TRACK=1`. The one thing that carries free text is feedback you or your agent deliberately send (`reticle feedback` / `reticle_feedback`) — never collected passively, redacted before sending, and separately disabled with `RETICLE_FEEDBACK=0`. *** ## State on disk: the `.reticle/` workspace The server persists project state under `.reticle/` in your repo: * **`flows/`** — recorded, replayable flows (the golden journeys). * **`baselines/`** — saved snapshots for diffing. * **`runs/`** — verification run artifacts (the evidence trail; writes are atomic and bounded so a crash never leaves a half-written artifact, and the directory is pruned). * **the capabilities contract** — the testids/signals/stores/flows the app advertises, frozen under a version so the public artifact can't break silently. This is plain, reviewable, version-controllable data — not a black box. *** ## Running at scale (multiple apps, multiple projects) * **Multiple apps / tabs on one bridge:** fine — each connection has a unique `SESSION_AUTO` id; a tool call targets the focused/most-recent session, or you pass an explicit `sessionId`. * **Multiple isolated projects:** give each project its own bridge port via `RETICLE_PORT` (set it in the MCP config and dial the same port from the app). A port already in use fails fast with a clear error rather than hanging. See [getting-started.md → Running multiple apps](getting-started.md#running-multiple-apps-at-once). * **CI / no MCP:** `reticle verify ` replays the saved flows headlessly and exits non-zero on failure — the same verdict artifact the MCP path produces, with no agent in the loop. *** ## Open-core licensing (what's free, what's protected) * The embeddable **SDK** (`-core`, `-browser`, `-react`) is **Apache-2.0** — safe to ship inside your own app. * The **server / CLI** is under the **Functional Source License (FSL-1.1, Apache-2.0 future)** — source-available, converts to Apache-2.0 over time. * Enterprise-only features live behind a license gate and are clearly separated. See [LICENSE](../LICENSE) and each package's own `LICENSE` file. The licensing *mechanism* is open and inspectable; activation is offline (no phone-home). *** ## Where to go next * [getting-started.md](getting-started.md) — wire Reticle into your app in a couple of minutes. * [benchmarks.md](benchmarks.md) — how we measure, and the honest results vs the alternatives. * [usage.md](usage.md) — the full tool reference and advanced modes. * [CONTRIBUTING.md](../CONTRIBUTING.md) — the development loop and the rules. # Benchmarks Source: https://reticle.mintlify.app/benchmarks # How we benchmark Reticle (and why you can trust the numbers) > This page assumes **zero** testing background. By the end you'll understand what software testing is, why AI coding agents made it urgent, how to tell a good verification tool from a bad one, and exactly how Reticle measures up against the main alternatives — including the places Reticle loses. If a term looks like jargon, it's defined the first time it appears. > > *Where the pictures come from, stated accurately: the **SVG charts** (`bench/artifacts/`) are generated from measured raws by `bench/harness/charts.mjs`. The **headline PNG cards** (`assets/readme/`) are screenshots of hand-authored HTML in `assets/benchmarks/src/`, rendered by `assets/benchmarks/render.mjs` — their figures are typed in by a human and are **not** read from `history.jsonl`, which this line previously claimed. Treat a card as a design asset that has to be updated by hand when a number moves, and `bench/SCORECARD.md` (plus its freshness banner) as the source of truth.* *** ## Part 1 — The problem, from first principles ### What is "testing"? When you build software, **testing** is the act of checking that it actually does what it's supposed to. You click the button; does the thing happen? You submit the form; did the order get saved? There are roughly three ways teams do this: 1. **Manual testing** — a human clicks through the app. Accurate, but slow and easy to skip. 2. **Automated tests** — code that clicks through the app for you. Fast to re-run, but expensive to write and notoriously **flaky** (they break for reasons unrelated to real bugs). 3. **Nothing** — ship and hope. More common than anyone admits: surveys put \~44% of teams with no automated testing at all. A **regression** is the specific bug this page cares about: something that *used to work* and quietly *stopped working* after a change. Regressions are insidious because the feature looked done — until a later edit broke it and nobody noticed. ### Why AI agents made this urgent AI coding agents now write a large share of new code. They're good at *producing* code. They are bad at one specific thing: **knowing whether the code they wrote actually works.** The agent edits files, says *"done ✅"*, and moves on. It never opened the browser. So **you** become its QA department — clicking around to discover that "done" wasn't done. Developers describe being *"gaslit by coding assistants."* This is the **"done lie."** The honest fix is to give the agent a way to *check its own work* — to drive the real running app and confirm the feature actually happened. That's what Reticle does. This page is about proving it does it **well**, not just that it does it. *** ## Part 2 — How would you even measure "good"? Imagine three verification tools. How do you decide which is best? Two things matter, and they pull against each other: * **Coverage** — does it actually *catch the bug*? A tool that misses regressions is useless no matter how cheap it is. * **Cost** — how much does it make the AI *read* to do its job? Every observation is fed into the model's limited context as **tokens** (the unit models read/write in). A tool that floods the context with thousands of tokens per look is slow, expensive, and crowds out the actual work. A tool can cheat either axis: be cheap by looking at almost nothing (and miss bugs), or be thorough by dumping everything (and blow the budget). So the real metric has to reward **both at once**, with coverage as a hard floor. That gives us our headline number: > **Verification Efficiency = real regressions caught per 1,000 tokens spent looking** — and it only counts once the tool catches **100% of the bugs** with **zero false alarms** on a known-good control. (Historically abbreviated "VE"; the catch-rate floor was "RCR".) A **false positive** (or "false alarm") is crying wolf — flagging a bug when nothing is wrong. We require zero, because a tool you can't trust when it's quiet is a tool you'll learn to ignore. Verification Efficiency: catches per 1,000 tokens, gated on 100% catch rate *** ## Part 3 — The competitors, and why these three We compare Reticle against the two most credible agent-native browser tools, because they're what an AI agent would otherwise reach for: * **Playwright MCP** — Microsoft's tool that lets an agent drive a browser via the accessibility tree (not screenshots). The closest "serious" alternative. * **Chrome DevTools MCP** — Google's tool exposing Chrome's DevTools to an agent. * **(Baseline) screenshot agents** — the common "let the model look at a picture" approach. Why not compare to traditional test frameworks (Playwright the library, Cypress, etc.)? Because those are written and maintained by humans; they don't answer "can an *agent* verify its own work in the loop." The three above do, so it's an apples-to-apples agent comparison. Tool versions are pinned in `bench/raw/run-meta.json` so any run is reproducible. *** ## Part 4 — The three measurement passes Different questions need different rigs. We run three (kept in the raw files as "Layer A/B/C"): 1. **Observation-cost pass** (no AI model involved). For each bug scenario we run each tool's natural recipe and measure the exact size of what it returns — characters, bytes, and a tokenizer count. No model means no randomness: the token cost of *looking* is measured precisely and repeatably. 2. **Full-agent-loop pass** (a real model drives). Here an actual AI agent uses each tool end-to-end, and we record the *authoritative* token usage the model reports. This is the real-world number; it needs an API key, so it's run periodically rather than on every change. 3. **Replay pass** (no model). Reticle can record a flow once and *replay* it deterministically with no AI at all. This measures the cost of re-checking a known flow — the thing a test suite does over and over, every commit. ### The scenarios We inject **10 realistic regressions** into a demo app plus **one no-bug control** (to catch false alarms). The bugs span the failure modes that matter — and deliberately include ones that are *invisible to a screenshot*: | Scenario | The bug | Why it's here | | ------------------------ | ----------------------------------------- | -------------------------------------------- | | Hidden API 500 | A request silently returns a server error | A screenshot can't see a failed network call | | Wrong status 404 | A request 404s | Same — non-visual | | CORS blocked | A request is blocked by the browser | Non-visual | | Silent DOM removal | A KPI card vanishes with no error | Tests "did content disappear?" | | Route break | Navigation doesn't change the page | Common SPA bug | | Missing modal | A dialog fails to open | Interaction regression | | Console error, intact UI | The page looks fine but logs an error | Looks-fine-but-broken | | Layout shift | The grid jumps / shifts | Only visible in pixels/geometry | | Broken form validation | A form accepts bad input | Logic regression | | Network timeout | A request hangs forever | A request that never resolves never "logs" | | **Control (no bug)** | Nothing is wrong | Anything flagged here is a false positive | Three of these (failed request, console error, hung request) are **categorically invisible to a screenshot** — no number of pictures will ever catch them. That's a core part of the story. *** ## Part 5 — The honest results, on two very different apps We ran this two ways on purpose: a **controlled toy app** (where we can inject exact bugs and measure detection) and a **real production app** (where the numbers are messy and honest). The story holds in both — and the real app is where it gets interesting. One honest test, two apps — Reticle has the highest Verification Efficiency on the controlled app and the lowest observation cost on the real dashboard ### 5a — The controlled toy app (the demo) Measured on the observation-cost pass (numbers regenerate from `bench/raw/`): | Tool | Bugs caught (of 10) | Detection accuracy | Avg tokens per look | Verification Efficiency | | ------------------- | ------------------- | ------------------ | ------------------- | ----------------------- | | **Reticle** | **10 / 10** | **1.00** | **815** | **12.27** | | Chrome DevTools MCP | 8 / 10 | 0.82 | 758 | 10.55 | | Playwright MCP | 9 / 10 | 0.91 | 1,292 | 6.97 | * **Reticle is the only tool that caught every regression**, with zero false alarms on the control. * DevTools is a hair cheaper per look (758 vs 815 tokens) — **but it's cheaper because it catches less.** On the metric that combines both (Verification Efficiency), Reticle leads at 12.27 vs 10.55. * Playwright catches a lot but is \~1.6× more expensive per look, so its efficiency is lowest here. ### 5b — A real production app (the Reticle dashboard) A toy app is a fair lab, but it's small. The harder, more honest test is a **real, complex app** — the [Reticle](https://reticle.sh) dashboard itself: React 19, authentication, live data, \~15 routes, a node-graph view, virtualized lists. We embedded the SDK (the Vite plugin) and drove the authenticated app with all three tools. Observing it **once** (the primary snapshot + the network log): Observing the real Reticle dashboard once — Reticle 1,023 tokens vs DevTools 1,357 vs Playwright 2,193, and Reticle alone asserts success via the app's own signal | Tool | Snapshot | Network | **Observe total** | Can it assert success? | | ------------------- | -------- | ------- | ----------------- | --------------------------------------------------------------------- | | **Reticle** | 678 | 345 | **1,023** | ✅ via the app's own `auth:logged-in` signal — **46 tok, un-fakeable** | | Chrome DevTools MCP | 1,105 | 252 | 1,357 | ❌ DOM/network only — can describe, can't verify intent | | Playwright MCP | 1,522 | 671 | 2,193 | ❌ DOM/network only | On a small page everything is cheap and the gap is modest. On a **big** page the structured read pulls ahead: Reticle is **2.1× leaner than Playwright MCP** and the cheapest overall. And only Reticle can read the app's own program state (`authenticated`, `userId`, `activeProjectId`) and assert the login *actually worked* from a signal the app emits — the others can only look at the DOM and the network and **guess**. ### 5c — The kicker: a real bug, caught live Here's what makes the real-app test matter. On the **very first run, before we instrumented anything**, Reticle's network observation flagged two endpoints returning **`500`** — `GET /api/v1/projects` and `/recovery/incidents`, both failing with `column "deleted_at" does not exist` (a database migration that hadn't been applied). **The page rendered fine.** The sidebar loaded, nothing looked broken — a screenshot agent would have called it *"done."* Reticle saw the broken backend underneath, in one look. That is the whole thesis, demonstrated on a real app we didn't cherry-pick: **"looks done" ≠ "is done," and the difference is usually non-visual.** (We then fixed the migration; all endpoints 200.) ### 5d — What each tool can actually do Cost is half the story; capability is the other half. The marks below are what's **first-class and built-in** to each tool (all three are good tools — they compose: drive with theirs, assert with Reticle): Capability matrix — Reticle alone asserts via the app signal, reads program state, maps DOM to source, and replays deterministically; Playwright and DevTools win on driving sites you don't own and true pixels ### Where Reticle loses (stated plainly) Honesty is the point of this page, so here are the places Reticle does **not** win: * **Raw cheapness on a single trivial look:** DevTools can be a few percent cheaper per observation when it isn't catching the harder bugs. We trade those tokens for catching more. * **True pixels:** a screenshot is the *actual rendered frame*. A bug that only shows up in real paint (a font that failed to load, a GPU/compositing glitch) can be caught by a screenshot diff and missed by Reticle's structural reads — *unless* Reticle is explicitly asked to do a visual diff. We're honest that structure isn't pixels. * **The full-agent-loop pass** is measured for the three tools but a screenshot-agent variant is still future work; we don't claim a head-to-head there we haven't run. ### The part that compounds: re-running The numbers above are for **one** verification. But a test suite's real job is the **same** check, over and over — every commit, every CI run. Here the picture changes shape: * Reticle records a flow once, then **replays it with no AI model at all** — re-resolving each element and re-asserting the outcome — for **\~175–210 tokens per run**. * Playwright MCP and DevTools MCP have no replay: re-checking means an agent **re-drives the whole flow with the model every time**, costing tens of thousands of tokens per run. That's a **\~128–184× cost difference per re-run**, and it grows with how often you run. This is where the "much cheaper than screenshots" claim becomes dramatic rather than incremental. Re-run cost: Reticle replays with no model; competitors re-drive every time ### The large-page test (where the wedge is biggest) On a small page, structured reads and screenshots are both cheap, so the gap is modest. The advantage shows on a **big** page. On a deliberately large grid (thousands of DOM nodes): * A **full page snapshot** costs \~**3,636 tokens**. * A **targeted verify loop** (find one button → act → assert the success signal) costs \~**279 tokens** — and stays flat as the page grows to 5,000 rows. That's a **13× difference**, and against a screenshot agent it's larger still: one screenshot at a normal window size is \~**1,365 image tokens** *per look*, a real loop takes several looks, and the screenshot still can't see the non-visual bugs. ### Many agents at once (the parallel wedge) A fleet of agents (or a parallel suite) verifying the same app doesn't need a browser each. Reticle keeps **one** headless Chromium and leases each agent an **isolated context** (separate cookies/storage/DOM). Measured on 16 flows: **35.4s** one-at-a-time vs **5.2s** across 8 leased contexts — **6.78× faster**, \~30s saved per batch, 8-way peak concurrency. The alternative — a browser per agent — costs hundreds of MB and seconds of startup each; the pool's edge grows with agent count up to its cap (`multi-agent-throughput`). *** ## Part 6 — Why we believe these numbers (fairness + reproducibility) * **Every number comes from a committed harness**, not a slide. Re-run it: `pnpm bench`. * **A no-bug control** runs in every pass, so a tool can't look good by flagging everything. * **Tool versions are pinned**; the raw payloads are saved to `bench/raw/` so anyone can audit them. * **The token counter is a tokenizer**, not a guess — and where we use a proxy tokenizer instead of a specific model's, we say so and lean on *relative* differences, which are robust to the choice. * **We publish where we lose** (above). A benchmark that only flatters its author isn't a benchmark. *** ## Part 7 — What this means for you * **If you're a developer using an AI agent:** Reticle lets the agent confirm its own change actually worked — across the network call, the console, the route, the state — for a few hundred tokens, not a screenshot's thousands. "Done" starts meaning done. * **If you run CI / a platform team:** the replay pass is the headline — deterministic re-verification with no model, so a known flow re-checks for \~200 tokens at \~0% flake, every commit. * **If you're evaluating tools:** the axis that matters is *bugs caught per token, gated on catching them all*. Cheapness that misses regressions is a false economy. You are now equipped to read any verification benchmark critically: ask "what's the catch rate, what's the false-positive rate, and what's the cost *per re-run*?" — and be suspicious of any vendor that won't show you where they lose. *** ## Part 8 — At enterprise scale (the honest "if a big company used this") A fair question from a large engineering org (think a Datadog- or Salesforce-sized team): *does this actually pay off at our scale, or only in a demo?* Here's the honest accounting, with every assumption stated so you can re-run it with your own numbers. **These are projections from the measured per-run costs above, not a measured enterprise deployment — labeled as such.** ### Where it pays off (and compounds) The win is **regression re-runs**, because that cost is paid over and over. Take a mid-size surface: * **50 golden flows**, re-verified on **every PR**, at **200 PRs/day**. * Reticle replays each flow **deterministically, with no model**: \~**200 tokens/flow/run** (measured). * The agent-driven alternative re-drives each flow with an LLM: \~**30,000 tokens/flow/run** (measured Layer-C comparison). | | Per flow / run | 50 flows × 200 PRs/day | Per year (\~250 working days) | | ------------------------- | -------------- | ---------------------- | ----------------------------- | | Reticle replay (no model) | \~200 tok | \~2.0 M tok/day | \~0.5 B tok/yr | | Agent re-drive (LLM) | \~30,000 tok | \~300 M tok/day | \~75 B tok/yr | That's a **\~150× difference on the recurring axis**, and it scales linearly with flows × runs — the two numbers an enterprise has *a lot* of. The deterministic replay also means **\~0% verdict flake** (measured over repeated identical runs), which at this scale is the difference between a trusted gate and one engineers learn to ignore (recall: flaky suites get abandoned — that's the documented failure mode Reticle is built to avoid). And because replay needs no model, it has **no per-run API spend and no rate-limit ceiling** — it runs in CI like any other deterministic check. ### Where it does NOT help (stated plainly) An honest enterprise evaluation has to include the limits: * **Authoring still costs model tokens.** Recording/annotating a flow the first time uses an agent. The economics only turn positive once a flow is re-run enough times to amortize that — which is exactly the enterprise case (many runs), but a flow you run twice won't pay off. * **Tier-1 oracles need instrumentation.** The strongest, un-fakeable assertions come from app signals the team adds (a \~30-second opt-in per success event). Without them you get Tier-2 (network/route/ state) — still strong, still better than "element exists," but not the magic tier. Be realistic about the instrumentation rollout across a large codebase. * **It is not a full replacement for an existing E2E platform on day one.** Reticle is verification in the dev/agent loop and in CI; a large org with a mature Playwright/Cypress estate adopts it alongside, starting with the agent loop and the highest-value journeys, not as a big-bang migration. * **Structure is not pixels.** As in Part 5: a purely visual regression (a paint/font/compositing bug) needs the opt-in visual diff; the always-on structured reads won't catch it. * **Per-project isolation is a setup step.** Multiple apps/teams on one machine each want their own bridge port (`RETICLE_PORT`) — trivial, but it's configuration a platform team should standardize. ### The honest bottom line for a large org Reticle is **most efficient exactly where big companies hurt most**: a large number of journeys re-verified a large number of times, where an LLM-re-drive approach's cost grows without bound and a flaky human-authored suite gets abandoned. The recurring cost drops by \~two orders of magnitude and becomes deterministic and model-free. It is **least differentiated** for one-off checks, purely visual regressions, and before the team has invested in Tier-1 signals. A sober rollout: start with the agent dev-loop, instrument the top revenue-critical journeys with success signals, wire `reticle verify` into CI for those, and expand from there. *** ### Appendix — the raw artifacts * `bench/SCORECARD.md` — the one-page standing with the plain-language legend. * `bench/METRIC.md` — the exact metric definitions. * `bench/METHODOLOGY.md` — full design: controls, scenarios, fairness. * `bench/history.jsonl` — every measured run over time. * `bench/raw/` — raw payloads and per-scenario results (gitignored locally; regenerated by `pnpm bench`). *** ## SDK overhead — what instrumentation costs the app An observability layer that slows the app corrupts its own performance verdicts, so Reticle holds itself to a budget: **total instrumentation overhead below 3% of main-thread time**, measured on the *hostile* fixture — a page that never goes quiet (a \~10 messages/sec feed, a 60fps ticker, a large list), not a polite demo. The same page is loaded in one browser under three conditions — full SDK, observers with the HUD suppressed, and Reticle absent — and Chrome's own cumulative `TaskDuration` is compared over an identical window (condition order rotated so warm-up can't favour any one of them). Three conditions, not two, so that what instrumenting costs is separable from what the HUD costs. | | main-thread task time | busy | | ----------------------------- | --------------------- | ------------------------------ | | full (observers + HUD) | 1.669 s | 20.9% | | observers only (`?nopresent`) | 1.627 s | 20.3% | | Reticle absent (`?no-hud`) | 1.610 s | 20.1% | | **instrumentation** | **+0.21 pp** | below the ±1.23 pp noise floor | | **presenter HUD** | **+0.53 pp** | opt out with `present: false` | **Result: instrumentation overhead is smaller than the method can resolve — report it as `< 1.2 percentage points`, inside the 3% budget.** The HUD is measured and reported separately, because it is a developer-visible affordance you can turn off, not a cost of observing. Getting here required correcting the method and then fixing a real bug. The two-condition version of this bench compared the full SDK against Reticle being absent, so it charged instrumentation for the HUD too, and reported **+5.85 pp — FAIL**. Decomposing it showed almost none of that was instrumentation: a single `backdrop-filter: blur(24px)` on the HUD panel was costing **+4.03 pp** on its own — more than every observer combined — because a backdrop blur re-samples everything behind it whenever that content changes, and this fixture repaints at 60fps. Removing it took total main-thread time down 22%. Full history, including a fix that measured nothing and was reverted rather than kept, is in `bench/overhead/README.md`. Reproduce it yourself with `node bench/overhead/measure.mjs` (see `bench/overhead/README.md`, which records the ruled-out causes). causes). # Deploy checks Source: https://reticle.mintlify.app/deploy-checks # Verification at the deploy choke point (Vercel / Netlify) The strongest place to enforce verification is the moment code becomes a preview URL. Every deploy already produces one, and both Vercel and Netlify let a third party attach a **check** to it — pass/fail, shown on the PR, with no workflow file to write. That makes the non-developer story complete: the SDK is auto-injected by the build plugin, flows are minted from toolbar recordings with auto-proposed consequences, and verification runs at publish — **without anyone writing a test.** > Docs-only in v2.2.0. The pieces below all ship today (`reticle verify` exits 0/1 and persists a run artifact); what is *not* built is a hosted Reticle app that registers itself as a provider. Wire it with the CI recipe until that exists. ## The shape ``` git push → preview deploy → Reticle verifies the preview URL → check passes/fails on the PR ``` `reticle verify ` is the whole integration surface: * exits **0** when every saved flow passes, **1** otherwise — the only contract a check needs, * prints a legible ✓/✗ report for the PR log, * persists a `ReticleVerificationRun` artifact (`.reticle/runs/.json`) so `reticle gate` and `reticle_run_export` can consume the same verdict. ## Recipe A — CI (works today, any provider) ```yaml theme={null} # .github/workflows/verify.yml name: reticle on: [deployment_status] jobs: verify: if: github.event.deployment_status.state == 'success' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - run: pnpm install --frozen-lockfile - run: npx playwright install --with-deps chromium # Reticle drives the preview and owns its own browser + daemon. - run: npx reticle verify "${{ github.event.deployment_status.target_url }}" --timeout 60000 ``` The job's own pass/fail becomes the PR check. Nothing else is required. **Non-loopback previews need pairing.** For a real preview URL (not `localhost`), Reticle injects `reticle.connect()` with a one-time token and allow-lists the preview origin — so the app does not need to be rebuilt per environment. Confirm the SDK actually runs on the deployed build: a production build that tree-shakes the dev-only SDK will connect to nothing, and `verify` will (correctly) fail with *"no app connected."* ## Recipe B — the Checks API pattern (what a hosted Reticle would do) Both providers expose the same shape, which is why this is one integration rather than two: | Provider | Hook | Result surface | | -------- | ------------------------------------------------------ | ----------------------------- | | Vercel | Deployment webhook → run the check → report back | Checks on the deployment / PR | | Netlify | Deploy-succeeded webhook → run the check → report back | Deploy summary / PR | The flow is: receive the deploy webhook → `reticle verify ` → post the verdict (and the `repair.failurePackets[]` from the run artifact) back as the check output. The artifact is stable and versioned precisely so a host platform can render it without parsing logs. ## Pair it with the local gate The deploy check catches what reaches a preview. `reticle gate` catches it earlier — an agent that edits a covered file cannot "finish" without re-verifying: ```bash theme={null} reticle gate --since origin/main # exit 1 unless passing artifacts cover the affected flows ``` Use both: `gate` in the agent's Stop hook (see `agent-cheatsheet`), `verify` at the deploy. They read the same run artifacts, so a green gate locally and a green check on the PR mean the same thing. ## Honest limits * **`verify` needs one connected session.** If several tabs of the app are open against the same daemon it refuses rather than guessing which to drive. * **It replays flows sequentially against one tab**, so flows must not depend on each other's leftover state (a flow that logs in contaminates the next one). Author self-contained flows, or use `reticle_flow_verify { parallel }`, which gives each flow an isolated context. * **No saved flows means nothing to verify** — `verify` fails rather than reporting a vacuous pass. # Desktop apps Source: https://reticle.mintlify.app/desktop-apps # Desktop apps: Electron & Tauri Reticle verifies desktop apps the same way it verifies web apps — from **inside** the app, over a localhost WebSocket. There is no browser to open and no screenshot to interpret. * [How you actually test a desktop app](#how-you-actually-test-a-desktop-app) * [Electron](#electron) * [Tauri](#tauri) * [What IPC looks like to an agent](#what-ipc-looks-like-to-an-agent) * [Troubleshooting](#troubleshooting) *** ## How you actually test a desktop app The usual question is *"it's a desktop app — what URL does the agent open?"* None. The direction is reversed from what browser tooling trains you to expect: ```text theme={null} ┌──────────────┐ MCP ┌───────────────────┐ WebSocket ┌───────────────────────┐ │ coding agent │◀────────▶│ reticle daemon │◀──────────────▶│ your Electron/Tauri │ │ │ stdio │ (localhost:4400) │ the app │ window + the SDK │ └──────────────┘ └───────────────────┘ dials OUT └───────────────────────┘ ``` Your app **connects to the daemon**, not the other way round. So the workflow is: 1. Start the daemon once: `npx @reticlehq/server serve` 2. Start your app exactly as you always do: `npm run dev`, `electron .`, `cargo tauri dev`. 3. That's it. `reticle status` now lists your window as a session, and the agent drives it. `reticle open` has nothing to open for a desktop app and will say so, and there is no `reticle drive` for desktop — those launch a browser, which is not what you are testing. Headless works on both runtimes; see below. ## What works, measured Every tool below was run against both demo apps against a live daemon. The rows in **bold** are the ones a committed battery re-proves on every change — `pnpm test:e2e:desktop`, which starts a real Electron main process and a **packaged** Tauri binary (`tauri://localhost`, not `tauri dev`) and drives them headless. The rest were measured by hand. That distinction matters: this table used to report a hand-run score with nothing in the repo that reproduced it, so it could go stale without anything failing. | Capability | Electron | Tauri | Note | | --------------------------------------------- | -------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | sessions, snapshot, query, inspect | ✅ | ✅ | `inspect` returns `src/App.tsx:104` in a dev build; a packaged production renderer has no source map, so it reports `n/a` | | capabilities, state (live store) | ✅ | ✅ | `reticle_state` reads the real store | | act (click/fill/type/select) | ✅ | ✅ | | | **act\_and\_wait, wait\_for, assert** | ✅ | ✅ | signal / state / route / net / console predicates | | console errors | ✅ | ✅ | catches what the UI never shows | | network — HTTP | ✅ | ✅ | on `file://` a relative URL has no origin; use an absolute one | | **network — IPC** | ✅ | ✅ | `ipc://`, incl. failures. Electron: `invoke` and `sendSync` carry a verdict; a one-way `send` is recorded as `oneWay: true` with NO status, because the renderer never learns the outcome. Tauri: both the `ipc://` (macOS/Linux) and `http://ipc.localhost` (Windows) transports | | route | ✅ | ✅ | use a **hash** router — see below | | storage, animations, observe, explore | ✅ | ✅ | | | baseline (semantic), record → replay, crawl | ✅ | ✅ | `crawl` found real anomalies in both | | navigate (reload) | ✅ | ✅ | | | **screenshot / visual\_diff** | ✅ | ✅ | one line in Electron's main process; one Rust command in Tauri — see below. Reticle's own presenter panel is hidden for the shot, so a baseline records your app and not the instrument | | **drivable while window occluded** | ✅ | ✅ | including minimized, app-hidden, and behind a fullscreen app on another Space | | **headless** | ✅ | ✅ | Electron never shows the window; Tauri shows, loads, then hides | | **a missing preload is DECLARED, not silent** | ✅ | n/a | without `@reticlehq/electron/preload` every IPC call is invisible, so verdicts carry `coverage: partial` naming the missing line instead of reading clean | | network\_mock, viewport | ❌ | ❌ | need a Reticle-driven browser | ### Screenshots **Electron: one line in the main process.** ```js theme={null} const { installReticleCapture } = require('@reticlehq/electron/main'); const win = new BrowserWindow({ ... }); installReticleCapture(win); ``` That is all — no CDP flag, no extra packages, works on a packaged `file://` renderer. `reticle_screenshot` and `reticle_visual_diff` then work on your app. Alternatively, since an Electron renderer *is* Chromium, `--remote-debugging-port=9222` + `RETICLE_CDP_URL=http://127.0.0.1:9222` also works and additionally enables `fullPage` (the main-process route captures the window as composited, so it cannot scroll-stitch). **Why the main process, and not a screen capture.** `webContents.capturePage()` reads the window's own backing store. Capturing a screen *region* instead was tried and deliberately rejected: it photographs whatever is on top, so an app window behind your editor yields a picture of the editor — saved as a visual baseline that a later diff would trust. A screenshot tool that can silently return another window's pixels manufactures exactly the false green Reticle exists to eliminate. One caveat remains: a fully occluded or minimized window is only partially composited, so parts of the capture may come back blank. Bring the window forward for a complete image — but it is never the wrong window. **Tauri: one Rust command.** ```rust theme={null} tauri::Builder::default() .invoke_handler(tauri::generate_handler![reticle_tauri::reticle_capture]) .on_page_load(reticle_tauri::on_page_load) ``` Nothing on the JavaScript side: the SDK invokes the command through Tauri's own internals, because Tauri has no preload stage where a shim could be installed. `reticle_screenshot` and `reticle_visual_diff` then work on your app, including headless. `reticle_capture` renders the webview rather than reading the screen — like Electron's `capturePage()` — so it needs no screen-recording permission, cannot return another window's pixels, and is correct with nothing on screen at all. Each platform uses its own webview API: | Platform | API | Status | | ---------- | ---------------------------------------- | -------------------------------------------------------------------------------------- | | macOS | `WKWebView.takeSnapshot` | Verified against a running app | | Linux, BSD | WebKitGTK `webkit_web_view_get_snapshot` | Snapshot + PNG encoding verified under `xvfb`; not yet driven through a full Tauri app | | Windows | WebView2 `CapturePreview` | **Untested — compiles, never executed** | The Windows path is written and type-checked against the real `webview2-com` API (which caught two genuine type errors), but nobody has run it on Windows. CI now re-checks it against that target on every PR (`cargo check --target x86_64-pc-windows-msvc`), so "compiles" is a gate rather than a claim — it had been asserted for months by a workflow comment while no such job existed. Executed is still a different word from compiled: it is shipped rather than withheld so it can be tried, and labelled rather than listed flatly so that trying it is a choice. If it works for you, say so and this row changes; treat a green from it as unconfirmed until then. All three capture the visible viewport by default, so a baseline taken on a developer's Mac is comparable against the same app in Linux CI. On a platform with no webview API to call, capture reports no-provider rather than returning a plausible wrong image. **`{ fullPage: true }` works on Tauri/Linux only.** WebKitGTK can render the whole document offscreen; `takeSnapshot` (macOS) and `CapturePreview` (Windows) only give what is composited, and Electron's `capturePage()` is the same. Asked for a full page they cannot produce, all of them return `{ ok:false, reason:'full-page-unsupported' }` rather than quietly handing back the viewport — a baseline that omits everything below the fold, while every later diff of it reports green about a region that was never captured. No baseline is written on a refusal. An app that already has its own capture can expose `window.__reticleIpc.capture()` returning a PNG path instead; the SDK prefers it over the built-in command. ### A correction: the Tauri macOS "liveness constraint" was wrong Earlier versions of this document said a Tauri app on macOS is only drivable while its window is on the active Space and unoccluded, and that hiding it suspends the webview. **That is not true, and the mistake is worth recording because it cost three features.** Re-measured against the live app, a loaded Tauri webview answers Reticle commands at full speed while: minimized, app-hidden with Cmd-H, fully occluded, on another Space behind a fullscreen app, and with no window on screen at all. A full 43-tool drive passes in every one of those states. What actually failed was narrower: **a webview that has never been presented never loads its page.** Every "suspension" experiment hid or moved the window from `setup`, i.e. before the first present, so the page never ran and every command timed out at 8s. The timeouts were real; the diagnosis was not. The `alwaysOnTop` workaround was then built to fix a problem that did not exist, measured as "still broken" for the same reason, and deleted. The lesson generalises past this document: four experiments agreeing does not make a conclusion controlled, if all four share the same confound. ### Headless **Electron: yes.** `show: false` plus `backgroundThrottling: false` in `webPreferences`. The second one is load-bearing — Chromium runs an unshown window's timers in slow motion, which turns every settle wait into a flake. Screenshots still work, because `capturePage` reads the backing store rather than the screen. Verified with a full tool drive against a window that was never shown. **Tauri: yes — show, load, then hide.** ```rust theme={null} .on_page_load(reticle_tauri::on_page_load) // hides the window when RETICLE_HEADLESS=1 ``` Run with `RETICLE_HEADLESS=1 pnpm tauri dev`. Nothing ends up on screen, and screenshots keep working because the capture renders the webview rather than the screen. The ordering is the whole trick. Hiding the window during `setup` hides it before the webview has ever been presented, and a webview that has never been presented never loads its page — which is what made headless Tauri look impossible. Hiding it after its first page load leaves everything running. Verified with a full 43-tool drive plus a screenshot and a visual diff against a window that is not on screen. `xvfb-run -a pnpm tauri dev` also works on Linux and needs no app-side change at all. ### How it compares to Playwright MCP Both attached to the same running Electron app, same task ("archive a todo, then verify it worked"): | tool | \~tokens | ms | verdict | | ---------------------- | -------- | ------- | ------------------------------------------ | | reticle (lean) | **350** | 1364 | caught the failure | | playwright-mcp | 1069 | **980** | blind to it — no network/IPC in its output | | playwright-mcp → Tauri | — | — | cannot attach (no CDP in WKWebView) | Playwright MCP is faster. It is also structurally unable to see an IPC failure, because its channel is the accessibility tree. Full method, numbers and caveats: [`bench/desktop`](../bench/desktop). ### Routing: use a hash router A packaged renderer runs on `file://`, where `pushState('/settings')` rewrites the URL to `file:///settings` — a path that does not exist, so the next reload lands on a blank page and the app is gone. This is why HashRouter is the standard choice for packaged Electron/Tauri apps. Reticle's route observer handles both, and a `{ kind: 'route', contains: … }` assertion matches the fragment. ## Electron Two steps. The first is the ordinary web setup; the second is the only desktop-specific part. **1. The renderer** — one line in `vite.config.ts`, exactly like a web app: ```ts theme={null} import { reticle } from '@reticlehq/vite-plugin'; export default defineConfig({ base: './', // file:// needs relative asset paths plugins: [react(), reticle({ desktop: true })], }); ``` `desktop: true` does the two things a desktop shell needs and a web app must never get: the plugin also runs for `vite build` (a packaged renderer is a production build with **no dev server**, so the default serve-only gating would ship an app with no `connect()` at all), and `connect()` is called with `allowInProduction` so the SDK's production backstop does not refuse to start. Keep it behind your own dev-only build so an instrumented bundle can never reach a release binary. Nothing to add in your app code. (You can still call `reticle.connect()` by hand and pass `inject: false` if you want control.) **2. The preload** — one line, before you expose anything: ```bash theme={null} npm i -D @reticlehq/electron ``` ```js theme={null} // electron/preload.cjs require('@reticlehq/electron/preload'); const { contextBridge, ipcRenderer } = require('electron'); contextBridge.exposeInMainWorld('api', { loadTodos: () => ipcRenderer.invoke('todos:load'), }); ``` That line is what makes your main-process calls visible. It has to live in the preload, and it is not a stylistic choice: `contextBridge.exposeInMainWorld` hands the renderer a **deeply frozen, non-configurable** object, so nothing running in the page can instrument `window.api`. The preload is the last point where `ipcRenderer.invoke` is still an ordinary, writable function. Patching there covers every channel you go on to expose, whatever you named it. **Preload sandboxing.** A sandboxed preload can't resolve `node_modules`, so the bare `require` above fails. Either bundle your preload (electron-vite and Electron Forge do this by default — the require is inlined at build time and sandboxing stays on), or set `sandbox: false` in `webPreferences`. **Packaged renderers.** An app that loads its renderer with `loadFile` runs on `file://`, which is a production Vite build. Pass `allowInProduction: true` to `connect()` for that mode, or keep the SDK gated behind `import.meta.env.DEV` so it never enters the shipped binary at all. Working example: [`apps/electron-smoke`](../apps/electron-smoke). ## Tauri Frontend side, nothing desktop-specific: ```ts theme={null} // src/main.tsx import { reticle } from '@reticlehq/browser'; if (import.meta.env.DEV) reticle.connect(); ``` Nothing else is needed for IPC. A Tauri `invoke` travels as a real `fetch` to Tauri's `ipc://` custom protocol, so Reticle already sees it; every `invoke('load_todos')` shows up as `ipc://load_todos`. Reticle also reads Tauri's `Tauri-Response` header, because the transport answers **HTTP 200 whether the Rust command returned `Ok` or `Err`** — without that translation a failed command would be recorded as a successful request. The one required step is **CSP**. Tauri ships a restrictive default that blocks the bridge WebSocket before it opens, and the failure is silent from the app's side. In `src-tauri/tauri.conf.json`: ```json theme={null} { "app": { "security": { "csp": "default-src 'self' ipc: http://ipc.localhost; connect-src 'self' ipc: http://ipc.localhost ws://localhost:4400 ws://127.0.0.1:4400" } } } ``` Keep `ipc: http://ipc.localhost` in `connect-src` — Tauri v2 needs it for `invoke` itself. Add your dev-server origin too if you use `devUrl`. This is a dev-only config; drop the `ws://` entries from your release config. Working example: [`apps/tauri-smoke`](../apps/tauri-smoke). ## What IPC looks like to an agent A desktop app reaches its backend over IPC, not HTTP. `fetch`/`XHR` patching cannot see that, so without the IPC observer every backend call in your app is a blind spot — `reticle_network` returns nothing, `act_and_wait` has no in-flight request to settle on, and `assert { net }` is vacuously true. That is a false green by construction. Reticle records each IPC call as an ordinary request, so the tools you already use work unchanged: ```jsonc theme={null} // reticle_network { urlContains: "ipc://" } { "calls": [ { "method": "ipc", "url": "ipc://todos:load", "status": 200, "ms": 134 }, { "method": "ipc", "url": "ipc://todos:archive", "status": 500, "ms": 83 }, ], } ``` IPC has no status code; `200`/`500` are synthetic, mapped from whether the call succeeded or failed, precisely so that `reticle_network { status: 500 }` and `assert { kind: "net", status: 500 }` keep working. On Tauri you will see `status: 500` next to `statusText: "OK"` — that is not a bug: the transport really did answer 200, and the 500 is the command's own verdict. `ok` is authoritative, and on Electron `error` carries the message your main process returned: ```jsonc theme={null} // reticle_assert { predicate: { kind: "net", urlContains: "ipc://todos:archive", status: 500 } } { "pass": true, "evidence": { "url": "ipc://todos:archive", "ok": false, "status": 500, "error": "archive is not implemented in the backend", }, } ``` Both example apps ship a planted false green — an Archive button that updates the UI optimistically and swallows the rejection. The screen says "archived", a screenshot agrees, a DOM assertion agrees. Only the IPC record disagrees. That is the case desktop support exists for. ## Troubleshooting **`reticle status` shows no session.** Check the app's console (Electron: devtools, or forward `console-message` to your terminal — a desktop renderer has no visible console otherwise). A refused connect always logs why. **Tauri: nothing connects and the app console shows a CSP violation.** The `connect-src` above is missing or does not include your daemon's port. **Electron: `module not found: @reticlehq/electron/preload`.** The preload is sandboxed. Bundle it, or set `sandbox: false` — see [Electron](#electron). **IPC calls do not appear, but the app works.** Electron: the shim's `require` must run *before* your preload captures its own reference to `ipcRenderer`. Put it on the first line. Tauri: `invoke` imported from `@tauri-apps/api/core` is observed; a hand-rolled `postMessage` protocol is not, and neither is Tauri's `postMessage` transport fallback on platforms where the `ipc://` custom protocol is unavailable. **Why not just patch `invoke` / `window.api` directly?** Because neither can be. Tauri defines `__TAURI_INTERNALS__.invoke` as `writable: false, configurable: false`, and Electron's `contextBridge` object is deeply frozen and installed non-configurably. Both were verified, not assumed — which is why the two runtimes use the two different mechanisms above rather than one uniform monkey-patch. # Enterprise Source: https://reticle.mintlify.app/enterprise # Reticle for enterprises > Premium access (how you get + activate it), what's gated, the security/data-handling posture, and the licensing model. Integration mechanics live in [`platform-integration.md`](./platform-integration.md). ## How premium access works (offline, no phone-home) Enterprise (`ee/`) features ship **inside the open package** — they're source-available (free for development, testing, and evaluation). A **license key activates them in production**. Activation is verified locally with Ed25519; nothing about your usage ever leaves your machine. **The flow, end to end:** 1. **Buy** — contact **[hey@reticle.sh](mailto:hey@reticle.sh)**; we issue you a signed license key (org, plan, expiry, feature set). 2. **Install** — set it on the machine running the Reticle server: ```bash theme={null} export RETICLE_LICENSE_KEY="" # the release already bakes the issuer public key (RETICLE_LICENSE_PUBLIC_KEY) ``` 3. **Verify** — `reticle license` shows your status: ``` active licensed to Acme Corp (enterprise), expires 2027-06-20 · features: sso, audit eval evaluation mode — enterprise features run free (no issuer key configured) missing set RETICLE_LICENSE_KEY to activate enterprise features in production expired renew to keep using enterprise features ``` 4. **Unlock** — enterprise features now run in production; without a valid key they refuse to run there (a clear error, never a silent half-feature). In eval/dev they always run free. 5. **Renew** — keys carry an expiry; `reticle license` warns before it lapses. > Procuring a license: contact **[hey@reticle.sh](mailto:hey@reticle.sh)**. Keys are issued offline and signed with Ed25519; the activation you run (`reticle license`) verifies them locally with no network call. ## What's gated (and the roadmap) The licensing **mechanism** is open core (inspectable, FSL) — only the **features** under `ee/` are gated: * **Today:** the activation gate + an example gated feature (audit event recording). * **Roadmap** (the reliably enterprise-only set): **SSO/SAML, SCIM, RBAC / team permissions, audit logs, multi-org management, verify-before-merge policy gates, and the hosted control-plane connectors.** These are the things a security/compliance org pays for; the core verification engine stays free forever. > What's premium vs free, and pricing, are business decisions for the owner — this doc describes the *mechanism*, not the price list. ## Security & data handling The honest one-pager a security review needs. Reticle is built so the answer to "where does our data go?" is **nowhere — it runs on your machine, in your infra.** | Question | Answer | | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Does the SDK ship to production? | **No** — dev/preview-only, tree-shaken from production builds. | | Where does the server run? | **Localhost** — the bridge binds `127.0.0.1`; the verify endpoint is localhost-bound + token-guarded (constant-time), with request/body-size/timeout limits. | | Does anything phone home? | **No app data, ever.** License checks are offline (Ed25519). The CLI sends anonymous, opt-out usage metrics only (random id + event names — no code, no PII; see [telemetry](telemetry.md)); disable fleet-wide with `RETICLE_TELEMETRY=0` or per-machine with `reticle telemetry disable`. Feedback your team explicitly sends us (`reticle feedback`) is the only free text that ever leaves; it is never collected passively, is redacted client-side, and is disabled fleet-wide with `RETICLE_FEEDBACK=0`. | | Where do artifacts live? | Your disk: `.reticle/runs/.json` (atomic writes, bounded retention), `.reticle/flows/`, `.reticle/contract.json`. You own them. | | What can the server read? | The DOM/network/console/routing/state of the app under test — locally. | | Leak risk downstream? | The **`prod-preview` profile** redacts source `file:line`, raw bodies, and app-state values. | | Path safety | Run/flow ids are validated as single path segments on read **and** write (no traversal). | **Verify it yourself:** the SDK + server are source-available — read the tree-shaking, the `127.0.0.1` bind, and the offline license verify. `SECURITY.md` has the disclosure process; an SBOM is available on request. SOC 2 is a GA-stage item (no Reticle-hosted data exists today to certify); the posture above is the honest current state. ## Licensing model (per package) | Scope | License | Why | | ------------------------------------------------------------------------------------------------------- | ------------------------------ | ---------------------------------------------------------------------------------- | | Embeddable SDK (`browser`, `protocol`, `react`, `babel-plugin`, `next`, `vite-plugin`, `eslint-plugin`) | **Apache-2.0** | safe to ship inside your customers' apps; explicit patent grant | | Server / CLI / MCP (`server`, `test`, umbrella) | **FSL-1.1-ALv2** | free for any use except reselling Reticle itself; converts to Apache after 2 years | | Enterprise features (`packages/server/src/ee/`) | **Reticle Enterprise License** | source-available; free for dev/eval, license key required in production | Embedding / OEM / enterprise: **[hey@reticle.sh](mailto:hey@reticle.sh)**. # Flows Source: https://reticle.mintlify.app/flows # Flows, the recorder & self-healing — record once, run forever Reticle turns an interactive run into a **git-checked, replayable program** stored under `.reticle/`. Flows are anchored on **meaning** (testid + signal), not volatile element refs or coordinates, so they survive refactors — and when an anchor does drift, Reticle tells you *why* and can repair it. This is what makes Reticle "the project's living test suite a human seeds and an agent maintains." > All of this is on-disk and human-readable, so flows are reviewed in PRs and diffed like code. ## The `.reticle/` directory When you record or save a contract, Reticle writes a git-checked workspace next to your app: ``` .reticle/ contract.json # the app's testable surface (testids, signals, stores) flows/ create-drop.json # a recorded, anchored, replayable flow baselines/ # snapshot baselines (co-located) ``` The server resolves `.reticle/` from the working directory it runs in (your project root). A fresh agent can read `.reticle/contract.json` to learn the testable surface **without grepping your source**. ## The contract — advertise the testable surface In your app, declare what's testable (see [Step 6 in Getting Started](getting-started.md)): ```ts theme={null} import { registerCapabilities } from '@reticlehq/react'; registerCapabilities({ testids: ['add-task', 'checkout'], signals: ['order:saved'], stores: ['cart'], }); ``` Then persist it to disk so it's committed and any agent can read it: | Tool | What it does | | ------------------------- | ----------------------------------------------------------------------------- | | `reticle_capabilities()` | the live testable surface `{ testids, signals, stores, flows }` | | `reticle_contract_save()` | write the live capabilities to `.reticle/contract.json` (versioned, diffable) | ## Create a flow **(a) Agent-recorded** — the agent drives, then saves: ```jsonc theme={null} reticle_record {action:"start"}({ recordingName: "create-task" }) reticle_act({ ref: "e7", action: "click" }) // … drive the golden path … reticle_record {action:"stop"}({ recordingName: "create-task" }) reticle_flow_save({ flowName: "create-task" }) // → .reticle/flows/create-task.json ``` **(b) Human-recorded (the recorder toolbar)** — with the presenter on (`present: true`), the floating panel hosts a recorder: a human clicks the golden path in the page and Reticle captures each interaction as a **semantic-anchored** step (testid, else role+name), then persists it via `reticle_flow_save_recorded`. The agent then runs and maintains it. *(First cut: structured annotations only — see below; free natural-language annotations are future work.)* ### What a flow file looks like ```jsonc theme={null} { "version": 1, "name": "create-task", "steps": [ { "tool": "reticle_act", "anchor": { "testid": "add-task" }, "action": "click" }, { "tool": "reticle_act", "anchor": { "testid": "add-task" }, "action": "click", "expect": { "signal": "task:added" }, }, ], "dynamic": [], // anchors whose CONTENT must not be asserted (LLM output) "success": { "signal": "saved" }, } ``` Each step binds to a **semantic anchor**, never a `eXX` ref: a `testid`/`signal` when available, else an auto-derived `component` anchor (component name + source `file:line`) for an element with no testid — so the flow stays stable with zero hand-added testids. Only when none of those resolve is a step kept `degraded: true` (a last-resort "add a testid here" marker) rather than silently dropped. ## Run a flow ```jsonc theme={null} reticle_flow {action:"list"}() // → flows on disk reticle_flow {action:"load"}({ flowName: "create-task" }) // → the flow JSON reticle_flow_replay({ flowName: "create-task" }) // re-resolve each anchor against the LIVE DOM, run it ``` **Watch it replay on the page.** When the presenter is on (`present: true`), a replay isn't silent — each step drives the real page, so the synthetic cursor flies to the element, the focus ring lands, and the activity log streams the journey live. You (or a teammate) literally watch the saved journey re-walk itself on your app, then see the verdict land. It's the fastest way to *see* that a flow still works — not just read a green checkmark. `reticle_flow_replay` returns a status: * `ok` — every anchor resolved and every `expect` held. * `drift` — an anchor missed (a testid was renamed, or a signal never fired). The result is **legible**: `{ step, anchor, drift: { reasonKind: "testid_not_found", nearest: "send-message" } }` — never a blind failure. (This is the "whose fault is it" principle.) * `error` — the flow file is missing/invalid, or a resolved action failed. Runtime failures include the failed step and a top-level error envelope. A testid-*preserving* refactor (you moved markup but kept the testids) still replays green. A step whose element has **no testid** is anchored on its component + source location (`{ kind: "component", component, source: { file, line } }`) — an auto-derived stable anchor, so a flow records cleanly with zero hand-added testids and replay re-resolves it via `reticle_query by:'component'`. ### The decision envelope — what to do next, not just pass/fail On a `drift` or `error`, the replay result carries a `decision` an agent can act on directly: ```jsonc theme={null} decision: { verdict: "drift", whatChanged: "testid \"fault-500\" not found", whereInSource: "src/Diagnostics.tsx:16", // file:line, from the component/source anchor suggestedFix: "rebind the anchor to \"fault-404\" (closest survivor)", nextAction: "rebind the anchor to \"fault-404\", or update the flow if the change was intended." } ``` This is the feedback a human reviewer used to give — made machine-actionable, so the agent decides its next move without one. ## Verify the whole suite in one call `reticle_flow_verify` replays **every** saved flow (or a named subset) deterministically — no LLM per flow — and returns one consolidated verdict. This is the regression check to run after any change: ```jsonc theme={null} reticle_flow_verify() // → { status: "fail", total: 4, passed: 3, failed: 1, // summary: "3/4 flows pass — 1 needs attention: ship-deploy", // failures: [{ flow: "ship-deploy", verdict: "drift", // whatChanged: "...", whereInSource: "src/...:NN", nextAction: "..." }] } ``` Passing flows are counted; only failures carry detail (token-cheap). Build → `reticle_flow_verify` → fix from each failure's `nextAction` → repeat — the autonomous regression loop. ## Self-healing — the agent maintains the flow When a testid is renamed, the flow drifts. `reticle_flow_heal` proposes — and optionally applies — the nearest-match rebind, so flows don't rot: ```jsonc theme={null} reticle_flow_heal({ flowName: "create-task" }) // PROPOSE only — never writes // → { status: "drift", applied: false, // proposals: [{ step: 0, from: "add-tassk", to: "add-task", confidence: 0.8 }] } reticle_flow_heal({ flowName: "create-task", apply: true }) // rewrite the anchor on disk // → { status: "healed", applied: true, proposals: [...] } ``` With `apply: false` the flow file is **never modified** — you get the proposed diff to review. With `apply: true` Reticle rewrites the drifted anchor(s) to the confident nearest match and a subsequent replay passes. A drift with **no** confident nearest match leaves the file untouched. ## Annotations (structured) `reticle_annotate` attaches a structured annotation that compiles into the flow, so replay is a *checked* re-run, not a blind macro: * `assert-signal` / `assert-visible` → a step `expect` predicate (the invariant). * `mark-dynamic` → a `flow.dynamic[]` entry — replay asserts the region's *presence* but **not its words** (the LLM-output case: assert `caption:generated`, ignore the caption text). * `success-state` → `flow.success` (the golden end condition). Pass `signal`/`testid`, or `statePath` (+ `store`, `equals`) to make the golden condition a **store-truth** assertion — the app's own source of truth, which no DOM read can reach (e.g. `statePath: "deployments.0.status", equals: "live"` fails the flow if a deploy only *looks* shipped on screen). State assertions are graded as consequences, so they satisfy the business-outcome oracle. ## Flows are your test suite `.reticle/` flows can be executed as CI specs — replayed with their `expect`/`success` predicates, skipping `dynamic` regions — via `@reticlehq/test`'s `flowsAsSpecs`. See [Testing with Reticle](testing.md). ## Tool reference | Tool | Args | Returns | | -------------------------------------------------------------------- | ------------------------ | -------------------------------------------------------- | | `reticle_contract_save` | `{ sessionId? }` | writes `.reticle/contract.json` | | `reticle_record {action:"start"}` / `reticle_record {action:"stop"}` | `{ recordingName }` | start/stop capturing the agent's acts | | `reticle_flow_save` | `{ flowName }` | persist the recording → `.reticle/flows/.json` | | `reticle_flow_save_recorded` | `{ flowName? }` | persist a human-recorded (toolbar) flow | | `reticle_flow {action:"list"}` | `{}` | flows on disk | | `reticle_flow {action:"load"}` | `{ flowName }` | the flow JSON | | `reticle_flow_replay` | `{ flowName }` | `{ status, steps, decision? }` (decision on drift/fail) | | `reticle_flow_verify` | `{ names?, sessionId? }` | suite verdict `{ status, passed, failed, failures[] }` | | `reticle_flow_heal` | `{ flowName, apply? }` | propose / apply nearest-match rebind | | `reticle_annotate` | `{ kind, … }` | compile a structured annotation into the flow | > Flow `name` must be a single safe path segment (no `/`, `\`, `..`, or leading dot). # Getting started Source: https://reticle.mintlify.app/getting-started # Getting Started with Reticle This walks you from zero to your agent verifying your app — step by step, with real code for real frameworks. \~10 minutes. * [What you're setting up](#what-youre-setting-up) * [Prerequisites](#prerequisites) * [Step 1 — Connect your coding agent (MCP)](#step-1--connect-your-coding-agent-mcp) * [Step 2 — Embed the SDK in your app](#step-2--embed-the-sdk-in-your-app) * [Vite + React](#vite--react) * [Next.js](#nextjs) * [Plain / other frameworks](#plain--other-frameworks) * [Step 3 — (React) component & source-file mapping](#step-3--react-component--source-file-mapping) * [Step 4 — Run it & verify the connection](#step-4--run-it--verify-the-connection) * [Step 5 — Your first verification](#step-5--your-first-verification) * [Step 6 — Make your app agent-legible](#step-6--make-your-app-agent-legible-optional-high-leverage) * [Common setups at a glance](#common-setups-at-a-glance) * [Troubleshooting](#troubleshooting) *** ## What you're setting up Three pieces, each tiny: ```text theme={null} ┌─────────────┐ MCP ┌──────────────────────┐ WebSocket ┌─────────────────────┐ │ coding agent │◀───────▶│ reticle bridge + server │◀─────────────▶│ your app + the Reticle │ │ (Claude Code)│ stdio │ (npx @reticlehq/server) │ localhost │ SDK (dev only) │ └─────────────┘ └──────────────────────┘ :4400 └─────────────────────┘ ``` Three pieces, each from the package for its audience: 1. **The MCP server** — your agent launches it with `npx @reticlehq/server mcp`; it hosts the tools *and* the WebSocket bridge your app connects to. You don't run it by hand; the agent does. 2. **The SDK** — `import { reticle } from '@reticlehq/react'`, a few lines in your app's dev entry point. 3. **(Optional) React adapter + source-mapping** — so `reticle_inspect` can tell the agent which component/file to edit (also from `@reticlehq/react`). Everything is **dev-only** and **localhost-only**. It's tree-shaken out of production builds. ## Prerequisites * Node 18+ and a package manager (npm/pnpm/yarn). * A coding agent that speaks MCP: Claude Code, Cursor, Windsurf, Claude Desktop, etc. * A web app you run locally in dev (any framework; React gets the richest features). *** ## Fastest path — `reticle init` From your project root: ```bash theme={null} npx @reticlehq/server init ``` It detects your framework, package manager, and React version, then: * **registers the Reticle MCP server once, globally, for each agent you have installed** — Claude Code (`claude mcp add reticle -s user`) and/or Cursor (`~/.cursor/mcp.json`) — so every project on this machine gets it; you never re-add it per project, * **writes a verification rule into your agent's instruction file** — `CLAUDE.md`, `.cursor/rules/reticle.mdc`, or `AGENTS.md` — so the agent knows to verify a feature with Reticle *after building it*, not only when you remember to ask (idempotent; appended below anything you already have), * installs the SDK kit (`@reticlehq/react`) and the right build plugin (`@reticlehq/vite-plugin` or `@reticlehq/next`) as dev dependencies, * **Vite:** adds the `reticle()` plugin to your config — which wires source mapping *and* `reticle.connect()` for you, so there is nothing else to edit, * **Next / other:** creates the dev component and prints the exact `withReticle` / mount / connect snippets to paste (it never half-edits a build config). The bridge + MCP server is a single process that serves all your projects, so it's registered at **user scope**, not in a per-project `.mcp.json`. Only the SDK (the `reticle()` plugin / connect call) is added per project. Re-running is safe (already-registered/already-patched steps are skipped). Preview without writing via `npx @reticlehq/server init --dry-run`. Flags: `--port N`, `--no-mcp`, `--no-install`, `--yes`. Then restart your dev server and skip to [Step 4](#step-4--run-it--verify-the-connection). The manual steps below explain what `init` sets up, if you prefer to wire it yourself. *** ## Step 1 — Connect your coding agent (MCP), once You don't start the server manually — your agent starts it via MCP. Register Reticle **once, at the user (global) scope** so every project picks it up — there's nothing to add per project. **Claude Code** — one command: ```bash theme={null} claude mcp add reticle -s user -- npx @reticlehq/server mcp ``` (`reticle init` runs exactly this for you. `-s user` is what makes it global; drop it for a project-local registration instead.) **Cursor** — add to your global `~/.cursor/mcp.json` (not per-project; `reticle init` writes this for you): ```jsonc theme={null} { "mcpServers": { "reticle": { "command": "npx", "args": ["@reticlehq/server", "mcp"] }, }, } ``` Other MCP clients (Windsurf, Claude Desktop, …) use the same `command`/`args` shape. Restart the agent so it picks up the new server. When it launches Reticle, the bridge starts listening on `ws://localhost:4400`. > Want a different port? Set `RETICLE_PORT` in the server `env` and pass the same URL to `reticle.connect({ url })` in Step 2. *** ## Step 2 — Embed the SDK in your app Install the SDK kit plus your framework's build plugin as dev dependencies (the kit re-exports the browser sensor, so one install gives both `reticle` and `install`): ```bash theme={null} npm i -D @reticlehq/react @reticlehq/vite-plugin # Vite; or: pnpm add -D … # Next.js instead? npm i -D @reticlehq/react @reticlehq/next ``` Then call `reticle.connect()` once, in dev only. Where you put it depends on your framework. ### Vite + React **Recommended — the Vite plugin (one line, does everything).** Add `reticle()` to your `vite.config.ts`: ```ts theme={null} import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import { reticle } from '@reticlehq/vite-plugin'; export default defineConfig({ plugins: [react(), reticle()], }); ``` This injects `reticle.connect()` for you *and* handles React 19 source mapping (Step 3) — so there's no entry-file edit and no separate Babel setup. `apply: 'serve'` means it's dropped from `vite build` entirely, so it can never reach production. (This is exactly what `reticle init` adds.)
Prefer to wire it by hand instead of the plugin? In your entry file (`src/main.tsx`), call `connect()` in dev only: ```ts theme={null} import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { reticle, SESSION_AUTO } from '@reticlehq/react'; import { App } from './App'; if (import.meta.env.DEV) { // SESSION_AUTO gives this tab a unique session id, so multiple apps/tabs never collide. reticle.connect({ session: SESSION_AUTO }); // connects to ws://localhost:4400 by default } createRoot(document.getElementById('root')!).render( , ); ``` On React 19 you then also need the source-mapping Babel plugin from Step 3. The Vite plugin above bundles both, which is why it's the recommended path.
### The pairing token (why some setups need one line more) The daemon auto-generates a **pairing token** on first run and stores it at `~/.reticle/pairing-token` (owner-only, `0600`). The bridge requires it, so another app running on `http://localhost:` can't quietly register or drive your session — only code that can read that file (your dev server, not a web page) can present it. * **Vite plugin users:** nothing to do. The plugin reads the token server-side and injects it into `connect()` for you. * **Next.js / hand-wired `connect()`:** your `connect()` runs in the browser and can't read the file, so pass the token in yourself. The simplest path is a shared secret: set `RETICLE_TOKEN` for the daemon (it uses that instead of auto-generating) and expose the same value to the client as `NEXT_PUBLIC_RETICLE_TOKEN`, then pass it to `connect({ token })` (see below). On a single-user machine you can also just read `~/.reticle/pairing-token` in your dev tooling and forward it the same way. ### Next.js Create a tiny client component and mount it in your root layout, dev-only: ```tsx theme={null} // app/reticle-dev.tsx 'use client'; import { useEffect } from 'react'; export function ReticleDev() { useEffect(() => { if (process.env.NODE_ENV === 'development') { // SESSION_AUTO = a unique id per tab, so several Next apps/tabs never collide on one session. // NEXT_PUBLIC_RETICLE_TOKEN carries the pairing token to the browser (see "The pairing token"). const token = process.env.NEXT_PUBLIC_RETICLE_TOKEN; void import('@reticlehq/react').then(({ reticle, SESSION_AUTO }) => reticle.connect({ session: SESSION_AUTO, ...(token ? { token } : {}) }), ); } }, []); return null; } ``` ```tsx theme={null} // app/layout.tsx import { ReticleDev } from './reticle-dev'; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {process.env.NODE_ENV === 'development' && } {children} ); } ``` ### Plain / other frameworks Anywhere your app boots in dev: ```ts theme={null} import { reticle, SESSION_AUTO } from '@reticlehq/react'; // Pass the pairing token (see "The pairing token" above); on a hand-wired setup you supply it yourself. if (location.hostname === 'localhost') reticle.connect({ session: SESSION_AUTO, token: import.meta.env.VITE_RETICLE_TOKEN }); ``` Or, with no build step, a script tag pointed at the bridge: ```html theme={null} ``` > **Want to watch the agent work?** Add `present: true` to `reticle.connect()` for a glowing border, a synthetic cursor that flies to targets, click/hover effects, and a narration HUD. See [usage §16](usage.md#16-presenter-mode-narration--fake-clock-watch--control). ### Running multiple apps at once It's common to have several apps open in dev — a few Next.js and React projects, or multiple tabs of the same app. Reticle handles this cleanly **as long as each connection has a unique session id**, which is exactly what `SESSION_AUTO` gives you (a fresh id per tab). The examples above all use it, so you get this for free. When more than one app is connected, an Reticle tool call targets the focused / most recently active one automatically, or you can pass an explicit `sessionId` to target a specific app. **Two separate projects, fully isolated.** If you want each repo to have its own independent Reticle bridge (separate sessions, separate `.reticle/` workspace), give each project its own port. Set the same port in both the MCP server config and the app's connection: ```jsonc theme={null} // project-b/.mcp.json — give this project its own bridge port { "mcpServers": { "reticle": { "command": "npx", "args": ["-y", "@reticlehq/server", "mcp"], "env": { "RETICLE_PORT": "4401" }, }, }, } ``` ```ts theme={null} // project-b's app — dial the same port reticle.connect({ session: SESSION_AUTO, url: 'ws://localhost:4401/reticle' }); ``` **On the Vite plugin?** You don't have a hand-written `connect()` to edit — the plugin injects it. Set the port on the plugin instead, and it bakes the matching URL in for you: ```ts theme={null} // project-b/vite.config.ts plugins: [react(), reticle({ port: 4401 })], ``` Either way, the rule is the same: **the app's bridge port must equal the daemon's `RETICLE_PORT`** — and it's the Reticle bridge port, never your dev-server port. Project A stays on the default `4400`, project B on `4401` — they never touch each other. (A port that is already in use now fails fast with a clear error instead of hanging, so a misconfiguration is obvious.) *** ## Step 3 — (React) component & source-file mapping This is optional but high-value: it lets `reticle_inspect` map a DOM element back to the **React component and the source file:line** — so when the agent finds a problem, it knows which file to edit. (The React adapter ships in `@reticlehq/react` — nothing extra to install.) ```ts theme={null} import { install as installReticleReact } from '@reticlehq/react'; if (import.meta.env.DEV) installReticleReact(); // call before reticle.connect() ``` **React ≤ 18:** that's all — it uses React's dev `_debugSource`. **React 19:** React removed `_debugSource`, so the source has to be stamped at build time. **If you added the `reticle()` Vite plugin in Step 2, this is already handled — skip ahead.** Otherwise add the Babel plugin (`@reticlehq/babel-plugin`) to stamp the source onto elements in dev: ```ts theme={null} // vite.config.ts import react from '@vitejs/plugin-react'; import reticleSource from '@reticlehq/babel-plugin'; export default defineConfig({ plugins: [react({ babel: { plugins: [reticleSource] } })], }); ``` > **Next.js:** verified on **Next.js 15 / React 19 (app router, SWC)**. For source-file mapping, use `@reticlehq/next` instead of the Babel plugin — it adds a **dev-only webpack pre-loader that keeps SWC** and stamps `data-reticle-source` so `reticle_inspect` returns `file:line` (e.g. `app/page.tsx:30`): > > ```js theme={null} > // next.config.mjs > import reticleNext from '@reticlehq/next'; > /** @type {import('next').NextConfig} */ > const nextConfig = {}; > export default reticleNext.withReticle(nextConfig); // no-op in production > ``` > > Component identity works with or without it (Next's internal wrappers are filtered out so you see your components, e.g. just `Page`). *** ## Step 4 — Run it & verify the connection 1. Start your app's dev server as usual (`npm run dev`). 2. Open it in the browser (the SDK connects when the page loads). 3. In your agent, ask it to confirm the connection: > "List Reticle sessions." The agent calls `reticle_sessions` and should see your tab: ```jsonc theme={null} { "sessions": [{ "sessionId": "my-app", "url": "http://localhost:3000/", "title": "…" }] } ``` If the list is empty, see [Troubleshooting](#troubleshooting). *** ## Step 5 — Your first verification Now just talk to your agent in plain language. For example: > "Add a 'Refresh' button to the header that re-fetches the dashboard data, then use Reticle to verify clicking it fires `GET /api/dashboard` and shows no console errors." What the agent does under the hood: ```jsonc theme={null} // finds the button it just added reticle_query({ by: "role", value: "button", name: "Refresh" }) // → ref e12 // clicks it reticle_act({ ref: "e12", action: "click" }) // → { since: 920 } // verifies the reaction reticle_assert({ timeout_ms: 2000, predicate: { allOf: [ { kind: "net", method: "GET", urlContains: "/api/dashboard", status: 200, since: 920 }, { kind: "console", level: "error", absent: true } ]}}) // → { pass: true } ``` You get a real, evidence-backed answer — and if it fails, the agent sees the reason (e.g. the call 404'd, or a `TypeError` in `Dashboard.tsx:88`) and can fix it and re-check. That's the whole loop. From here, the [Usage Guide](usage.md) covers every tool, the full predicate DSL, and a dozen real situations (login, long lists, eventual consistency, file uploads, LLM calls, regressions, and more). *** ## Step 6 — Make your app agent-legible (optional, high-leverage) The basics above work with zero app changes. These four additions make the agent dramatically faster and let it verify things the DOM can't express — they're what turn Reticle from "usable" into "magic." All are dev-only. **1. Stable `data-testid` on key elements.** Agents target testids more reliably than visible text (which changes with copy/i18n). Reticle matches testids *exactly*. ```tsx theme={null} ``` **2. `reticle.signal` for off-DOM facts.** When something matters but isn't visible — a save committed, a webhook arrived, an edit applied, an LLM caption finished — emit a signal the agent can assert on. This is the single highest-value instrumentation. ```ts theme={null} import { reticle } from '@reticlehq/react'; onSaved(() => reticle.signal('order:saved', { id, total })); // agent: reticle_assert({ predicate: { kind: 'signal', name: 'order:saved', dataMatches: { id: '*' } } }) ``` > **Recommended:** instead of importing `reticle` into components, inject a `createReticleEmitter()` emitter and pair each commit with `commitAndSignal(...)` so the mutation↔signal can't drift — `reticle.signal` stays the primitive underneath. See [integration-patterns.md](integration-patterns.md). **3. `registerStore` so the agent reads state directly.** No need to broadcast a signal for every fact — expose the store and the agent reads it via `reticle_state`. ```ts theme={null} import { registerStore } from '@reticlehq/react'; registerStore('cart', useCart); // pass the store itself → auto STATE_CHANGE diffs // agent: reticle_state({ store: 'cart' }) → { stores: { cart: {...} } } ``` **4. `registerCapabilities` so a fresh agent learns the surface without reading source.** ```ts theme={null} import { registerCapabilities } from '@reticlehq/react'; registerCapabilities({ testids: ['refresh', 'cart-open', 'checkout'], signals: ['order:saved', 'cart:updated'], stores: ['cart'], }); // agent: reticle_capabilities() → the whole testable surface ``` > **Multi-domain apps:** prefer `registerReticleDomain({ testids, signals, stores })` co-located in one `reticle.ts` per domain — each self-registers and `reticle_capabilities()` assembles the union, so there's no central map to forget. See [integration-patterns.md](integration-patterns.md). > Watch the agent work: pass `present: true` to `reticle.connect()` for a glowing border, a cursor that flies to targets, and a HUD; the agent can call `reticle_session {action:"narrate"}({ text })` to show its intent. See [usage §16](usage.md#16-presenter-mode-narration--fake-clock-watch--control). > **Hover-gated UI (tooltips, hover menus, pointer drag)?** Synthetic events can't trigger native `onMouseEnter`. Enable **real input** by launching your browser with `--remote-debugging-port=9222` and setting `RETICLE_CDP_URL` in the MCP server `env` — Reticle then drives real pointer input and `reticle_act` reports `inputMode:"real"`. See [usage §18](usage.md#18-real-input-mode--native-hover--drag). *** ## Going further Once the loop works, these turn ad-hoc runs into a maintained suite: * **[Flows, recorder & self-healing](flows.md)** — record a golden path once; Reticle saves it to a git-checked `.reticle/` flow anchored on testid+signal, replays it (with legible drift), and `reticle_flow_heal` repairs renamed anchors. * **[Testing with `@reticlehq/test`](testing.md)** — declarative `reticleTest` specs you run headless / in CI; flows can *become* the specs. * **[Human-in-the-loop control](human-control.md)** — with `present: true`, pause / message / end the agent from the floating panel. * **[Integration patterns](integration-patterns.md)** — the recommended zero-prod-bundle emit adapter, store-layer signals, and incremental adoption. *** ## Common setups at a glance Everything below comes from the `@reticlehq/react` kit plus your framework's build plugin. | Stack | SDK connect | Source mapping | | -------------------- | ------------------------------------------------- | --------------------------------------------------------- | | Vite + React (any) | `reticle()` plugin (auto) — or `connect()` | `reticle()` plugin handles it (incl. React 19) | | Next.js (app router) | `ReticleDev` client component in layout (dev) | `@reticlehq/next` (`withReticle`) → component + file:line | | SvelteKit | `src/hooks.client.ts` (written by `reticle init`) | `reticle()` plugin stamps `.svelte` → file:line | | Vanilla / plain HTML | `reticle.connect()` at boot (dev) | none — refs and testids only | ### What Svelte support is, and what it is not `reticle init` detects SvelteKit and writes both halves: a client hook that calls `connect()` (SvelteKit renders through `app.html`, so the plugin's HTML injection never fires) and `reticle()` in `vite.config`, which is what stamps `data-reticle-source`. **You get** `file:line` on every element in a `.svelte` component, plus everything the framework-agnostic core already gave you — DOM, network, console, routing, storage, actions — and `svelteStore` for reading a Svelte store (see [usage](usage.md)). **You do not get** component identity. `@reticlehq/react` walks the fiber tree to answer "which component rendered this element"; there is no Svelte equivalent, so snapshots carry the file and line but no component name. Stamping targets Svelte 5's compiler AST and also accepts Svelte 4's; `.svelte.ts` runes modules are code rather than markup and are not stamped. **It is still unverified.** There is no SvelteKit app in `apps/` and no CI gate for one, so nothing would tell us when this breaks — `reticle init` says so out loud in its plan. React, Next.js, Remix and Astro each have an app and a gate. Treat SvelteKit as wired and plausible, not as supported. **Vue is not supported.** The SDK is framework-agnostic so `connect()` may work, and `piniaStore` will read a Pinia store, but there is no detection, no `.vue` source stamping and no CI gate. *** ## Troubleshooting **`reticle_sessions` is empty / "no browser session connected"** * Run **`reticle status`** — it shows whether the daemon is up and which tabs are connected (url, health, pending flagged bugs) at a glance. No connected sessions means the SDK isn't reaching the bridge. * Is your app actually running and open in a browser tab? * Is `reticle.connect()` running? (Check it's inside your dev guard and the guard is true.) * Port mismatch? If you set `RETICLE_PORT`, pass the same URL to `reticle.connect({ url: 'ws://localhost:/reticle' })`. * Need to restart the daemon? **`reticle stop`** cleans it up — no `pkill` needed. The errors Reticle returns to the agent now carry a `recovery` hint for this exact situation (and for multiple/unknown sessions, a throttled tab, a missing baseline) — so the agent knows the next move. **The agent can't find an element** * Ask it to `reticle_snapshot({ mode: "interactive" })` to see what's actionable. * Add a `data-testid` to the element for a stable handle. * Narrow with `scope` (a CSS selector or a ref). **Assertions are flaky on async UIs** * Use `timeout_ms` on `reticle_assert` / `reticle_wait_for`. * Pass the `since` cursor returned by `reticle_act` so only post-action events count. **Source file isn't resolving on React 19** * Wire up `@reticlehq/babel-plugin` (Step 3). Without it, only component identity is available. **Nothing should run in production** * Keep `reticle.connect()` behind a dev guard (`import.meta.env.DEV` / `NODE_ENV`). The package is side-effect free and tree-shakes out when unused. As a backstop, `connect()` also self-disables when the build reports `NODE_ENV=production` (so an SSR healthcheck or a prod bundle opened on localhost won't activate it) — pass `allowInProduction: true` only for a deliberate prod diagnostic. ## Installing alongside a Next.js or React prerelease `@reticlehq/next` declares `peer next >=13`, and `@reticlehq/react` declares `peer react >=18`. If your app runs a **prerelease** — a Next.js canary/preview (`16.3.0-preview.9`) or a React RC — npm will refuse the install with `ERESOLVE`. That is npm's semver rule, not a Reticle restriction: a prerelease version satisfies a range only when some comparator shares its exact `major.minor.patch`. No floor-style range accepts it — verified, including `*`. Marking the peer optional does not help either, because npm still version-checks a peer that is present. Install with either of these instead. Both are safe; the floor is a real minimum, not a maximum: ```bash theme={null} npm install @reticlehq/next --legacy-peer-deps # or use pnpm, whose peer resolution does not hard-fail here pnpm add @reticlehq/next ``` # Integration patterns Source: https://reticle.mintlify.app/integration-patterns # Reticle Integration Patterns The basics in [Getting Started](getting-started.md) work with **zero app changes**. This doc is the *recommended* shape for a real codebase: a minimal production footprint, a signal layer that can't silently drift, and an adoption path that starts paying off on day one — no rewrite required. * [1 — Start here: reuse what you already have](#1--start-here-reuse-what-you-already-have) * [2 — Inject the emitter (zero prod bundle)](#2--inject-the-emitter-zero-prod-bundle) * [3 — Emit signals from the store layer, not N call sites](#3--emit-signals-from-the-store-layer-not-n-call-sites) * [4 — Self-registering domains (`registerReticleDomain`)](#4--self-registering-domains-registerreticledomain) * [5 — Keep the signal layer from rotting (`@reticlehq/eslint-plugin`)](#5--keep-the-signal-layer-from-rotting-reticleeslint-plugin) * [6 — Limitation: un-scriptable tabs → `reticle drive`](#6--limitation-un-scriptable-tabs--reticle-drive) * [Checklist](#checklist) *** ## 1 — Start here: reuse what you already have Adoption is **free → cheap → targeted**. You don't instrument everything; you reuse what you have, then add the handful of facts the DOM can't express. **1. Reuse your existing `data-testid` (free).** If you already test with Playwright or Cypress, your testids work in Reticle unchanged — `reticle_query({ by: 'testid', value: 'checkout' })` matches them exactly. No new markup, no new code. **2. Advertise the surface from your existing constants (cheap).** You already keep a `TestIds` constant object for your E2E suite — pass it straight in. Now `reticle_capabilities()` tells a fresh agent your whole surface without reading source. ```ts theme={null} import { registerCapabilities } from '@reticlehq/browser'; import { TestIds } from '../e2e/test-ids'; // the same constants your Playwright suite uses registerCapabilities({ testids: Object.values(TestIds), signals: ['order:saved'], stores: ['cart'], }); ``` **3. Add signals only at the \~20 commit points that matter (targeted).** Emit `reticle.signal(name, data)` at the moments the DOM can't show — a save committed, a webhook arrived, an edit applied, an async generation finished. You instrument the off-DOM facts you'd otherwise eyeball, not every line. ```ts theme={null} import { reticle } from '@reticlehq/browser'; onSaved(() => reticle.signal('order:saved', { id, total })); // agent: reticle_assert({ predicate: { kind: 'signal', name: 'order:saved', dataMatches: { id: '*' } } }) ``` That's day-one usefulness with no rewrite. The rest of this doc is how to do steps 2–3 *well* so the signal layer stays honest as the app grows. ## 2 — Inject the emitter (zero prod bundle) The #1 objection: *"I don't want a test tool in my production bundle."* The answer: components never import `@reticlehq/browser`. They depend on a tiny structural interface, `ReticleEmitter` (`{ signal, state }`), and the real emitter is injected once at the top. `createReticleEmitter()` returns an emitter that proxies to the connected `reticle` singleton and is a **safe no-op** until `reticle.connect()` runs — so nothing breaks in production or before connect, and **`@reticlehq/browser` stays out of the prod bundle.** ```ts theme={null} // app/emit.ts — the one place that touches @reticlehq/browser import { createReticleEmitter } from '@reticlehq/browser'; export const emit = createReticleEmitter(); // no-op until reticle.connect() ``` ```ts theme={null} // any component — depends on the interface, not the SDK import { emit } from '../emit'; function onSaved(id: string, total: number) { emit.signal('order:saved', { id, total }); } ``` The emitter re-checks the connection on every call, so you can create it at module load — before `reticle.connect()` — and it starts forwarding the moment Reticle connects. (See [getting-started Step 2](getting-started.md#step-2--embed-the-sdk-in-your-app) for where `reticle.connect()` goes.) This is the single highest-leverage decision; everything below assumes it. ## 3 — Emit signals from the store layer, not N call sites The smell: every store mutation hand-emits a signal right after it, and over dozens of call sites the two **drift** — a new mutation path forgets the emit and the contract silently breaks. Drive the signal from where the state actually changes instead. **Pattern A — store middleware (sketch).** One audited map from action → signal lives next to the store, so *state changed ⇒ signal fired* is structural, not a thing each call site remembers. ```ts theme={null} // store-with-reticle.ts — sketch: one audited transition map, not N call sites import { emit } from './emit'; const signalFor: Record readonly [string, Record]> = { reorderSections: (s) => ['section:reordered', { order: s.order }], addSection: (s) => ['section:added', { count: s.sections.length }], }; function dispatch(action: keyof typeof signalFor, run: () => State): void { const next = run(); const make = signalFor[action]; if (make !== undefined) emit.signal(...make(next)); } ``` **Pattern B — `commitAndSignal` (lighter).** When you don't want a middleware, pair the mutation and its signal in one call that can't drift. It runs `mutate()`, emits the signal exactly once, and returns the mutation's value. ```ts theme={null} import { commitAndSignal } from '@reticlehq/browser'; import { emit } from '../emit'; const next = commitAndSignal( emit, () => store.reorderSections(fromId, toId), 'section:reordered', deriveOrder(store.getState()), ); ``` If `mutate` throws, the mutation never happened — so **the signal is not emitted and the error propagates** unchanged. > **The documented exception:** genuinely view-level signals — render or async completions like `diff:shown` or `caption:generated`, which aren't store state — legitimately stay in your components. Only commit-point signals belong in the store layer. Pair this with store registration so the agent can *read* state instead of you emitting a signal per fact: `registerStore('workspace', useWorkspace)`, then `reticle_state({ store: 'workspace' })`. ## 4 — Self-registering domains (`registerReticleDomain`) Rather than maintaining one central flat-map of the whole testable surface (and remembering to wire each new area into it), co-locate one `reticle.ts` per domain that exports its `{ testids, signals, stores }` and self-registers. The capability registry assembles itself from every domain — later calls accumulate as a union, with no duplicates. ```ts theme={null} // features/sections/reticle.ts — co-locate a domain's testids + signals in one module import { registerReticleDomain } from '@reticlehq/browser'; export const SectionTestIds = { list: 'section-list', add: 'section-add' } as const; export const SectionSignals = { reordered: 'section:reordered' } as const; registerReticleDomain({ testids: Object.values(SectionTestIds), signals: Object.values(SectionSignals), stores: ['workspace'], }); ``` ```ts theme={null} // features/search/reticle.ts import { registerReticleDomain } from '@reticlehq/browser'; registerReticleDomain({ testids: ['search-input'], signals: ['search:ran'] }); ``` Importing both modules in dev makes `reticle_capabilities()` return the merged surface (`testids: ['section-list', 'section-add', 'search-input']`, `signals: ['section:reordered', 'search:ran']`, `stores: ['workspace']`). `registerReticleDomain` is a thin convenience over `registerCapabilities` — same merge-idempotent, HMR-safe semantics — so it composes with §1's "use your existing constants." (Named flows stay an explicit `registerCapabilities({ flows })` concern — their last-writer-wins semantics don't fit "accumulate from many domains.") ## 5 — Keep the signal layer from rotting (`@reticlehq/eslint-plugin`) A signal layer silently rots: someone adds a mutation path and forgets the signal, and the agent's contract breaks with no error. The lint rule catches it at the only moment that's cheap — review. The rule **`reticle/require-signal-on-mutation`** flags a function that calls a configured store mutator but emits no signal in the same function. It is a **safe no-op until you tell it which calls mutate state** (`mutators`) and which call emits a signal (`signalCallee`, default `signal` / `reticleSignal`): ```js theme={null} // eslint.config.js (flat config) import reticle from '@reticlehq/eslint-plugin'; export default [ { plugins: { reticle }, rules: { 'reticle/require-signal-on-mutation': [ 'warn', { mutators: ['set', 'reorderSections', 'addSection'], signalCallee: 'signal' }, ], }, }, ]; ``` Or turn it on with the shipped preset (warns, with empty no-op defaults you then configure): `plugin.configs.recommended`. A function that calls a mutator and a signal together passes; a mutator with no signal reports `store mutation without a mapped Reticle signal`. The documented view-level exceptions from §3 simply don't list those view callees as `mutators`, so they never fire. ## 6 — Limitation: un-scriptable tabs → `reticle drive` Reticle observes and drives a tab through the in-page SDK plus (optionally) CDP. It **cannot bring to front or recover a browser tab the OS won't let it script** — e.g. a backgrounded tab, or a non-default browser (Dia, etc.) reporting `hidden:true` / `throttled:true`. When that happens, `reticle_sessions` and every act/assert result carry a `session.recommendation` saying so. The escape hatch is **`reticle drive `** (add `--headed` to watch) — Reticle launches and owns a guaranteed-scriptable browser. See [usage §18](usage.md#18-real-input-mode--native-hover--drag) for the full note. ## Checklist * [ ] One `app/emit.ts` is the **only** module importing `@reticlehq/browser`; components import the emitter. * [ ] `reticle.connect()` is dev-gated; the prod bundle has no `@reticlehq/browser`. * [ ] Signals fire from the store layer (middleware or `commitAndSignal`); view-level exceptions are explicit. * [ ] Each domain self-registers via `registerReticleDomain`; `reticle_capabilities()` returns the full surface. * [ ] Existing Playwright/Cypress testids are reused, not duplicated. * [ ] `reticle/require-signal-on-mutation` is enabled with your `mutators` + `signalCallee`. * [ ] The team knows `reticle drive ` for un-scriptable tabs. # Local registry Source: https://reticle.mintlify.app/local-registry # Test unpublished Reticle changes in a real app (local registry) > **For normal use, Reticle is on public npm** — just `npm i -D @reticlehq/react @reticlehq/vite-plugin` (see [Getting Started](getting-started.md)). You only need this guide to test **local, unpublished changes** to the Reticle packages in a real external app before they ship. Because the `@reticlehq/*` packages depend on each other via the workspace protocol, plain `npm pack` tarballs don't resolve cleanly. The reliable way to exercise your in-progress changes in a real app is a tiny **local registry** (Verdaccio) — the same path CI uses to validate a publish. ## 1. Publish @reticlehq/\* to a local registry From the Reticle repo: ```bash theme={null} bash scripts/local-registry.sh ``` This starts a **fresh** Verdaccio on `http://localhost:4873`, creates a user/token, and publishes all `@reticlehq/*` packages there at the current workspace version: | Package | What you install it for | | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | **`@reticlehq/react`** | **install this** — the browser SDK kit you embed (re-exports the browser sensor, so one install gives both `reticle` and `install`) | | `@reticlehq/vite-plugin` | dev-only source mapping + `connect()` injection (Vite) | | `@reticlehq/next` | Next.js build wrapper (`withReticle`) | | `@reticlehq/server` | the bridge + MCP server (your agent runs it, `npx @reticlehq/server mcp`) | | `@reticlehq/babel-plugin` | React 19 source stamping (Babel) | | `@reticlehq/test` | write declarative, signal-bound specs (`reticleTest`) | | `@reticlehq/eslint-plugin` | the `require-signal-on-mutation` lint rule | | `@reticlehq/core` | shared wire contract (pulled in automatically) | For a browser app, install `@reticlehq/react` plus the build plugin for your framework (`@reticlehq/vite-plugin` or `@reticlehq/next`); `@reticlehq/server` is what your agent runs. (Verified: an external `npm i @reticlehq/react` resolves its graph, including `@reticlehq/core`, and imports correctly.) Leave the registry running. > Note: pre-2.0 docs used a single `@reticlehq/core` umbrella package that re-exported everything; it's been split into the audience-scoped packages above. ## 2. Point your app at the local registry In your app's project root, add an `.npmrc` (scopes only `@reticle` to the local registry; everything else still comes from npm): ```ini theme={null} @reticlehq:registry=http://localhost:4873/ ``` ## 3. Install + wire it up Install the SDK kit plus the Vite build plugin (source mapping + `connect()` injection): ```bash theme={null} npm i -D @reticlehq/react @reticlehq/vite-plugin # Next.js instead of Vite? npm i -D @reticlehq/react @reticlehq/next # optional: npm i -D @reticlehq/eslint-plugin # require-signal-on-mutation lint rule ``` Then follow [Getting Started](getting-started.md): embed `reticle.connect()` (dev only) from `@reticlehq/react`, add the MCP server to your agent, and (React) `install()` the adapter from `@reticlehq/react`. For the fastest agent loop, also do [Step 6 — make your app agent-legible](getting-started.md) (testids, `reticle.signal`, `registerStore`, `registerCapabilities`) and the [integration patterns](integration-patterns.md) (`createReticleEmitter` for zero prod-bundle cost). > **Upgrading.** The packages are currently **1.2.0**; new tools land as minor bumps. `scripts/local-registry.sh` resets Verdaccio and republishes the current version, so pull the latest in your app explicitly — `npm install @reticlehq/react@latest`: > > ```bash theme={null} > npm i -D @reticlehq/react@latest @reticlehq/vite-plugin@latest @reticlehq/eslint-plugin@latest > ``` **Run the MCP server** from the local registry too — `npx @reticlehq/server` *is* the server: ```jsonc theme={null} // .mcp.json — point npx at the local registry so it fetches @reticlehq/server from Verdaccio { "mcpServers": { "reticle": { "command": "npx", "args": ["--registry", "http://localhost:4873/", "@reticlehq/server", "mcp"], }, }, } ``` ## Next.js specifics (verified on Next 15 / React 19) `next.config.mjs`: ```js theme={null} import reticleNext from '@reticlehq/next'; /** @type {import('next').NextConfig} */ const nextConfig = {}; export default reticleNext.withReticle(nextConfig); // dev-only; keeps SWC; adds file:line mapping ``` Mount the SDK from a dev-only client component (see the Next.js section in [Getting Started](getting-started.md)). ## Real input for hover/drag (optional) Synthetic events can't trigger native `onMouseEnter`/pointer state (hover menus, tooltips, pointer drag). Enable **real input** so the server drives genuine pointer input and `reticle_act` reports `inputMode:"real"`: * **Easiest — `reticle drive`:** Reticle launches its own scriptable, headless-capable browser at your app URL (no flags to juggle): ```bash theme={null} npx --registry http://localhost:4873/ @reticlehq/server drive http://localhost:4310 # add --headed to watch ``` * **Or attach to your own browser:** launch it with `--remote-debugging-port=9222`, then point the MCP server at it via `env`: ```jsonc theme={null} // .mcp.json { "mcpServers": { "reticle": { "command": "npx", "args": ["--registry", "http://localhost:4873/", "@reticlehq/server", "mcp"], "env": { "RETICLE_CDP_URL": "http://localhost:9222" }, }, }, } ``` With neither set, Reticle stays synthetic (zero extra deps) and says so via `inputMode`. See [usage §18](usage.md#18-real-input-mode--native-hover--drag). ## Write replayable specs + git-checked flows * **Specs:** with `@reticlehq/test`, turn checks into `reticleTest("…", async t => { await t.act(...); await t.expectSignal(...) })` — signal/testid-bound, `reticle_clock` for determinism, `t.expectInputModeReal()` to skip-with-reason when real input isn't active. Run them headless via `reticle drive` (the same path CI uses). * **Flows:** record a flow once and Reticle writes it to a git-checked `.reticle/flows/.json` (anchored on testid/signal); `reticle_flow_replay` re-resolves anchors at run time and reports **legible drift** with a nearest-match; `reticle_flow_heal` proposes/applies the rebind. A fresh agent reads `.reticle/contract.json` to learn your testable surface without grepping source. ## When you're ready for real npm The same packages publish to public npm unchanged — `pnpm -r publish --access public` after `npm login`. The Verdaccio run above is a faithful rehearsal of that. ## Cleanup ```bash theme={null} pkill -f verdaccio # stop the local registry # remove the line from your app's .npmrc when you switch to published packages ``` # Multi agent testing Source: https://reticle.mintlify.app/multi-agent-testing # Multi-agent & multi-project testing Reticle is built for the messy real world: several apps running at once, ports that shift between runs, and many agents driving different flows of the same app in parallel — without each one spinning up its own Chromium. This page explains how that works and how to use it. ## The mental model * **One daemon per machine.** `reticle mcp` discovers a running daemon (via `~/.reticle`) or starts one. A crashed daemon's stale pidfile is reclaimed automatically, so you never chase "port already in use" or a zombie server. * **Identity is the app, not the port.** The build plugin stamps a stable `projectId` that travels in every connection. If your Next app usually runs on `:3000` but boots on `:3001` today, Reticle still knows which app it is — and an agent scoped to project A will never accidentally drive project B's tab. Origin is only a fallback hint. * **One browser, many contexts.** When agents need their own headless tabs, the daemon's **browser pool** launches a single Chromium and hands out isolated contexts (one per flow) — cheap, and capped so a big fan-out can't exhaust the machine. Over-cap requests queue. * **Attach-only.** Reticle never starts your dev server. It connects to an app you're already running (or opens a headless tab pointed at it). ## Manual testing — \~5 minutes 1. Add the plugin (Vite/Next) or one `reticle.connect()` call. (See [getting-started](./getting-started.md).) 2. Start your app as you normally do. 3. Open it in a browser — the in-page panel shows Reticle is connected. 4. Click around; flag anything that looks wrong with the "Flag a bug" annotator. The agent drains those with `reticle_session {action:"review"}`. ## Agent testing — \~2 minutes With the app running and instrumented, an agent drives a flow end to end: ```text theme={null} reticle_lease {action:"acquire"} { url: "http://localhost:3000/dashboard" } → { sessionId: "lease-…", ready: true, leased: 1, queued: 0 } reticle_act { sessionId, ... } # drive the flow reticle_assert { sessionId, ... } # verify intent reticle_lease {action:"release"} { sessionId } # free the slot ``` `reticle_lease {action:"acquire"}` opens a fresh isolated headless context against your **already-running** app, stamps the lease identity into the URL so the app's own SDK registers under a sessionId you can target, and waits until that tab has connected (`ready: true`) before returning — so the sessionId is usable immediately. Release when the flow finishes. ## 10 agents, 10 flows, one dashboard This is the design target, and it needs no special setup: * Each agent calls `reticle_lease {action:"acquire"}` for the same dashboard URL → its own isolated context (own cookies/storage) in the **one** shared Chromium. * The pool caps simultaneous contexts (`RETICLE_MAX_CONTEXTS`, default scales with CPU under a ceiling); extra acquires queue and proceed as slots free. * Flows can't bleed into each other — contexts are isolated and every session is scoped by `projectId`. * A single crashed page is reclaimed on its own, and if an agent crashes or hangs its lease stops being touched and the **lease reaper** reclaims the context after a TTL, freeing the slot. One dead agent never starves the others. `reticle_sessions` lists everything with `projectId` (group by app) and `leased` (pool context vs a human tab), so an orchestrator can see the whole fleet at a glance. ## Knobs | Env | Default | Effect | | ---------------------- | --------------------------------- | --------------------------------------------------- | | `RETICLE_MAX_CONTEXTS` | `min(8, cpus-1)` | Max simultaneous leased headless contexts. | | `RETICLE_PORT` | from `.reticle.json`, else `4400` | Daemon port (rarely needed — discovery handles it). | ## Why not just open many browsers? Ten Chromiums is hundreds of MB each and will thrash a laptop. Ten contexts in one browser is a few MB apiece and fully isolated — same correctness, a fraction of the cost. That's the whole point of the pool. **Measured** (`bench/harness/multi-agent-throughput.mjs`): 16 verification flows that take **35.4s** one-at-a-time finish in **5.2s** across 8 leased contexts on a single Chromium — **6.78× faster**, \~30s saved per batch, with all 8 contexts live at peak. The speed-up scales with agent count up to the cap; the win over launching a browser per agent grows with how much per-agent browser startup you avoid. # Platform integration Source: https://reticle.mintlify.app/platform-integration # Integrating Reticle > The one guide for adopting Reticle — for a team using a coding agent on its own app, and for an AI app-builder platform (Lovable / Emergent / Bolt) embedding Reticle in its generation pipeline. Reticle reads the program from *inside* a running app and returns a **verdict with evidence** ("did it actually work?"), not a screenshot. Enterprise/premium access lives in [`enterprise.md`](./enterprise.md). ## The loop ``` generate / edit → boot the preview → Reticle verifies the critical flows → verdict + evidence + repair │ PASS → ship & attach "verified ✓" │ FAIL → gate the deploy, feed repair packets to the fixer agent ``` One call replays the app's key journeys and asserts **program truth** — network cardinality, store/state, emitted signals, console — then returns a deterministic, un-hallucinatable verdict. *** ## Quickstart ### A. A team, agent on your own app (\~10 min) ```bash theme={null} npx @reticlehq/server init # auto-detects your framework, installs the kit + build plugin ``` Paste to your agent (Claude Code / Cursor / any MCP agent): `Follow https://raw.githubusercontent.com/reticlehq/reticle/main/SKILL.md` It runs the wizard once (Vite/Next plugin + SDK init + MCP config), then verifies on every change. Run your dev server, then ask the agent to *"verify it with Reticle."* ### B. A platform / CI, driven from your pipeline (no MCP, no human) ```bash theme={null} reticle serve --http --http-token "$TOKEN" --drive "$PREVIEW_URL" # localhost:7331 ``` ```js theme={null} const { run } = await ( await fetch('http://127.0.0.1:7331/verify', { method: 'POST', headers: { 'content-type': 'application/json', 'x-reticle-token': process.env.TOKEN }, body: JSON.stringify({ project: { name, framework, previewUrl }, trigger: { kind: 'oem', diffRef }, }), }) ).json(); if (run.verdict.status !== 'pass') { for (const p of run.repair?.failurePackets ?? []) fixerAgent.send(p.suggestedPrompt); // self-heal blockDeploy(run); } else attachToDeploy(run); // "verified ✓"; set profile:"prod-preview" to redact internals downstream ``` Or skip the HTTP server entirely with the one-shot CLI — `reticle verify ` drives the preview, replays the saved flows, prints the verdict, and exits non-zero on fail (ideal for a CI step). *** ## In-app SDK integration — the effort, by layer Reticle embeds a **dev/preview-only** SDK (`@reticlehq/browser`, Apache-2.0, tree-shaken from production). For a platform you add this **once to your generated-app template** → every generated app is verifiable. | Layer | What you add | Unlocks | Effort | | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------- | ------------------------------------ | | **1 — drive + DOM/network/console** | 1 build-plugin line + \~10-line dev-only `reticle.connect({…})` file (`npx @reticlehq/server init` does it) | broken routes, network status/cardinality (double-submit), console errors, persistence-after-reload | **Easy** (\~15 min) | | **2 — program-state truth** | `registerStore('app', store)` (1/store, pass the store itself so mutations emit diffs) + `reticle.signal('order:saved', …)` (1/consequence) + `data-testid`s | UI-vs-store desync, dead handlers, blast-radius, source mapping | **Easy–Medium** (an afternoon, once) | | **3 — governance (optional)** | `registerCapabilities(...)` (signals/stores/risk zones) + recorded flows with success oracles | risk policy + sharper verdicts | **Medium**, optional | Copyable patterns: `apps/bench-app/src/reticle-dev.ts`, `apps/next-smoke/app/reticle-dev.tsx`. Without instrumentation, Layer-1 checks still work via the driven browser; Layers 2–3 are what no out-of-page tool can see. *** ## What it catches that a screenshot can't | Silent failure (a generated app ships it) | How Reticle catches it | | ------------------------------------------------- | --------------------------------------------------- | | Mock data — POST 200, row shows, nothing persists | persistence/`state` oracle (doesn't survive reload) | | Dead handler — looks done, store never changed | `state` desync | | Double-submit — one click, two POSTs | `net { count: 1 }` | | Forbidden call — a must-never-fire endpoint fired | `net { count: 0 }` | | Missing validation — `"abc"` becomes data | flow oracle (error shown AND nothing created) | | Silent console error — logged, UI still renders | `console { absent: true }` | | UI-vs-store desync — the total lies | reads the store, contradicts the display | | Blast-radius — an action corrupts unrelated state | `state { hold:true }` invariant | Live, clickable demo of each: `apps/vibe-builder-demo/` (set `BUG_MODE=…`). Proven in CI: `packages/server/src/runs/generated-app-bugs.test.ts`. *** ## Exact steps per platform The shape is identical (in-app SDK in the template → verify in the sandbox → act on the verdict); the specifics differ by where each platform runs the preview. ### Emergent (Kubernetes pod per build, reverse-proxied preview URL) 1. Add `@reticlehq/browser` + `registerStore`/`reticle.signal` to the generated-app **scaffold** (one time). 2. In the build pod, alongside the preview: `reticle serve --http --http-token "$POD_TOKEN" --drive "$PREVIEW_URL"` (or import `ReticleRunner` in-process). 3. In the orchestrator's generate→test→iterate loop, `POST /verify` after the preview boots. 4. FAIL → route `repair.failurePackets[].suggestedPrompt` to the fixer subagent → re-verify (closes the loop). PASS → publish + attach the `prod-preview` run as the user-facing "verified ✓". ### Lovable (Vite/React generated apps, hosted preview) 1. Add the Reticle Vite plugin + dev-only `reticle.connect` to the project template (Lovable already templates Vite/React — it's one plugin line + the connect file). 2. Run `reticle serve --http --drive ` against the preview build in the generation worker. 3. Call `/verify` after each generate/edit; gate the "your app is ready" signal on `verdict.status === 'pass'`; feed repair packets back into the edit agent. ### Bolt.new / StackBlitz (WebContainer, in-browser runtime) 1. Add the SDK to the WebContainer app template; the app + Reticle bridge run in the WebContainer. 2. Since the runtime is in-browser, drive via the connected session (the SDK dials the bridge) rather than `--drive`; call verify from the Bolt agent after a build. 3. Same act-on-verdict: gate + self-heal with the repair packets. (Bolt already detects terminal/compile errors; Reticle adds the *runtime program-truth* layer it's blind to.) > Honest note: a platform can build a verification step itself. Reticle's case is the depth (program-state and source mapping), the determinism (0% flake, no LLM in the loop), the un-hallucinatable verdict, and a stable drop-in artifact. The reproducible benchmark in [`bench/`](../bench/README.md) measures the observation-cost and detection differences against other browser-automation MCPs. *** ## The verdict artifact `POST /verify` (and `reticle_run_export`) return a stable, versioned `ReticleVerificationRun` (defined in `@reticlehq/core`): `verdict` (pass/fail/partial, confidence, blockingRisks), `flows[]`, `checks[]`, `risks[]` (auth/payment/db/…), `repair.failurePackets[]` (what + where to fix), `evidence`. Render a legible report with `renderRunReport()` or `reticle_run_export { format: "report" }`. Profiles: `dev` (full) vs `prod-preview` (source + state redacted for downstream sharing). **Why trust it:** the verdict is mechanical — derived only from observed outcomes — so it can't report green for something it never ran (a severed backend reads as *fail*, never a confident pass). Proof: `packages/server/src/runs/false-green.test.ts`. ## Licensing for embedding The embeddable SDK is **Apache-2.0** (ship it in your customers' apps). The server/CLI is **FSL** (free, no competing resale). Enterprise features + the premium-access flow: [`enterprise.md`](./enterprise.md). OEM terms: **[hey@reticle.sh](mailto:hey@reticle.sh)**. # Telemetry Source: https://reticle.mintlify.app/telemetry # Telemetry Reticle collects a small amount of anonymous usage data to help us understand whether the tool is useful — which commands people run, which tools agents actually use, and whether people keep using Reticle after they try it. That's what this data is for, and all it is for: making the product better. This page is the complete description of what is collected. If something is not listed here, it is not sent. ## The short version * **Anonymous.** We cannot tell who you are, and we do not try. * **No code, no app data.** Nothing from the app under test — no DOM, no network traffic, no console output, no source, no file paths — ever leaves your machine. * **The one exception is feedback you deliberately send us**, and it is never collected passively. See [Feedback](#feedback) below. * **Opt out any time**, permanently, with one command: ```bash theme={null} reticle telemetry disable ``` ## What is sent Thirteen kinds of events, each a single small JSON object: | Event | When | Extra data | | ------------------------ | -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `reticle_installed` | The first time Reticle runs on a machine | — | | `cli_command_run` | You run a `reticle` command | Which subcommand (`verify`, `status`, …) and which flags were present, by name | | `daemon_started` | The local daemon starts | — | | `daemon_stopped` | The local daemon stops | A summary of the session — see below | | `verification_completed` | A verification produces a verdict | Whether it passed, whether Reticle refused to call a passing check verified, and **why** the verdict came out that way — see below | | `project_profiled` | Once per daemon start | The shape of the project — see below | | `version_changed` | You update or roll back | The two version numbers, and which direction | | `runtime_crashed` | The daemon hits an uncaught error | The error's type, **Reticle's own** stack frames, and the message with variables stripped — see below | | `mcp_client_connected` | An agent attaches to the daemon | Whether it is a reconnect, and how long the daemon had been idle | | `mcp_connection_lost` | The agent's MCP tools go away | Which stage (`first`, or `budget_spent` when it stopped retrying), the cause, and the attempt count. **At most twice per session** — one measured afternoon produced 547 reconnects, and an event each would bill for the pathology instead of measuring it | | `init_completed` | `reticle init` finishes | Whether it worked, and a classified reason when it did not | | `bug_found` | Reticle finds a defect in the app under test | The **kind** of defect (`signal-contradicted`, `console-error`, …) and how it was found — never what it was found in | | `feedback_submitted` | **Only** when you or your agent explicitly send feedback | The report — see [Feedback](#feedback). The AGENT's call does not wait for the network: the receipt says `accepted` (validated, redacted, queued), never `sent`, and a delivery that then fails is reported back on the agent's next tool result. `reticle feedback` typed by a human still waits, because a person at a terminal is owed the real answer | | `identified` | **Only** when you run `reticle identify` | What you chose to tell us — see [Telling us who you are](#telling-us-who-you-are) | **There is no per-tool-call event.** Tool usage is counted in memory and leaves once, inside `daemon_stopped`, as a histogram like `{"reticle_act": 40, "reticle_assert": 12}`. That is counts of tool NAMES from a fixed list we define — never arguments, results, selectors, or URLs. **Parameter and flag NAMES are collected; their VALUES are not.** We record that `reticle_act` was called with `ref` and `action`, and that you ran `reticle serve --headed --port`. We do not record what you set them to. This distinction is the whole safety property, and it is absolute for CLI flags: `--http-token` holds a secret, `--drive` holds a URL, `--storage-state` holds a file path, so no flag value is ever sent. For tool parameters there is one narrow exception — a short, explicit allowlist of parameters whose values are enums *we* defined (`action: "click"`), listed in [`argument-shape.ts`](../packages/server/src/telemetry/argument-shape.ts). A value outside that allowlist reports as `other`, so a future schema change cannot quietly start forwarding free text. `reticle_act`'s `args` — the text being typed into your app, which on a login form is a password — is **never** in that allowlist. `daemon_stopped` carries: how long the session ran, how many tool calls and of which tools, how long each tool took (total and worst case), how many failed, how many verifications ran, browser/lease connection attempts with their failure causes, and which MCP clients connected (`claude-code`, `cursor`). It also carries a snapshot of the **machine's** state — our own process's memory, free and total system RAM, load average and CPU count — taken at shutdown and again on any crash. This is what separates "your machine ran out of memory" from "Reticle has a bug", which otherwise produce identical-looking failures. No hostname, no username, no paths, no process list. ### Why a verdict came out that way `verification_completed` carries the **clause** that decided the verdict, from a fixed list we define: `proved`, `contradicted`, `assertion_failed`, `already_true`, `unclean_capture`, `vacuous_grade`, `outcome_pending`, `outcome_unread`, `unsettled`, `observation_lost`, `inconclusive`. It exists because `verified: "unknown"` covered seven different situations belonging to three different owners — your app, your agent, and Reticle's own blind spots — and they arrived as one value. It is a **name from our own vocabulary**, never a description of your app: `contradicted` says two channels disagreed, not which ones, about what, or on which page. When the clause is `unclean_capture`, one further name says which of our three losses caused it — `buffer_loss` (our server's event buffer), `transport_gap` (our browser-side queue), `blind_spot` (a boundary in the page, such as a cross-origin frame), or `other`. All four are facts about **Reticle's** ability to observe, not about what it observed. This field is how we found that Reticle was refusing to answer over windows that were completely intact. ### Bugs Reticle finds When Reticle catches a defect in the app it is verifying, it records **that a class of defect was found** — never what it was found in. The event carries the kind (`signal-contradicted`, `duplicate-request`, `console-error`, …) and how it surfaced, and nothing else: no selector, no URL, no element, no description of your app or its behaviour. This is the number we use to say whether Reticle works at all, and the one we would publish. It is counted conservatively on purpose — a defect explained by a contradiction is not also counted as a failed assertion, because an inflated number would be worse than none. ### Errors and crashes The SDK that runs inside your page reports its own failures too — an observer that could not start, a patch that would not install. It does this over the **local bridge it is already connected to**; the SDK still makes no outbound request of its own, and the daemon decides what (if anything) is reported onward. What travels is our module name (`network_observer`), the error type, and the message with variables stripped — never your page's URL, and never anything from your app. Errors are grouped by a **hash of the error's shape**, with every variable part removed first — so `no baseline named 'checkout-v3'` and `no baseline named 'login'` become one anonymous group and neither flow name is sent. Alongside the hash we send that stripped shape (`no baseline named *`) and the tool that produced it, because a hash on its own can be counted but never understood. A crash additionally carries **Reticle's own stack frames** — `resolveAnchor@act-tools.js:142` — plus the tool that was running, the preceding tool names, and the Node version and CPU architecture. Those frames are our published npm code, readable by anyone who unpacks the tarball. **Stack frames belonging to your application are dropped entirely**, along with node internals. In the example below only the two Reticle frames survive; your file, your function, and your home directory do not: ``` at doCheckout (/Users/ada/secret-app/src/checkout.tsx:42:9) ← dropped at resolveAnchor (…/@reticlehq/server/dist/tools/act-tools.js:142:19) ← sent ``` **One narrow exception, for the crash that otherwise says nothing.** A refused connection — `connect ECONNREFUSED` — has a stack that is *entirely* node internals, so the rule above correctly keeps nothing and the report arrives with no location at all. Those crashes now also carry the failing **syscall** (`connect`), the **errno** (`ECONNREFUSED`), whether the target was **loopback** (one boolean), whether the port was **one of Reticle's own** (the enum `reticle` / `other`), and the innermost frame naming **Node's own source** (`node:net:1637`). The address and the port number are used to compute those two answers and are then discarded — neither is ever sent. The Node frame is a line in Node's published source, not yours: it says a connect failed rather than a DNS lookup, and carries nothing about your machine, your app, or your directory layout. The frame is included **only** when the crash is a system error and no Reticle frame survived — the report that would otherwise be blind. `project_profiled` carries: the framework and its major version, a size **bucket** (`tiny` … `huge`, never a file count), whether it is a monorepo, its age in whole **weeks**, how many saved flows/baselines/runs exist, and which Reticle feature families have been used. It also carries whether the project has git at all, whether it has ever been pushed (`none` / `local_only` / `remote`), and which forge hosts it — `github`, `gitlab`, `bitbucket`, `azure`, `sourcehut`, `codeberg`, or just `self_hosted` for anything else. A private git host is usually `git..com`, so self-hosted repos report **only** that they are self-hosted, never the hostname. This is how we learn whether people are getting value from the whole product or only a corner of it. No file names, no paths, no flow names, no dependency list. Every event carries the same few fields: | Field | What it is | What it is not | | ----------------- | --------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `anonymousId` | A random UUID minted locally on first run, stored at `~/.reticle/telemetry-id` | Not derived from your name, email, hardware, or anything else about you | | `projectId` | A one-way SHA-256 hash of the git origin URL, or of the directory path outside a repo | Not reversible — we can count *distinct* projects, but not learn any project's name, URL, or location. Hashing the origin means one repo counts once instead of once per teammate and per clone | | `actor` | Whether a person or an agent caused this — a typed `reticle` command is a person, an MCP tool call is the agent | Not a claim about *why*. Whether you asked your agent to verify, or it decided to, lives in a prompt Reticle never sees, and we do not guess | | `sessionId` | A random id for this daemon run, held in memory and never written to disk | Not a device id and not persistent — a restarted daemon gets a new one. It only groups one run's own events together | | `projectIdSource` | Whether `projectId` came from a shared git origin or from the local directory path | Not the origin or the path. It exists so we can tell when "how many people share this project" is a real measurement — outside a pushed repo there is nothing shared to hash, so those rows always show one user | | `version` | The Reticle version running | — | | `os` | The platform (`darwin` / `linux` / `win32`) | Not the OS version, hostname, or hardware | | `ci` | Whether the run is inside CI | — | There are no IP-based profiles, no cookies, no fingerprinting, and no person profiles: events are processed in "personless" mode, so they are never joined into an identity. ## What is never sent Your code. Your app's DOM, network requests or responses, console logs, application state, or screenshots. File paths, project names, or git remote URLs. Your name, email, employer, or any account identifier. Environment variables. Anything typed into the app under test. The names of your flows, baselines, or tests. Error messages, and any stack frame belonging to your application. A few of these are worth being explicit about, because they are the ones a product team is most tempted by: * **We do not send your project's name or its GitHub URL.** `projectId` is a one-way hash. It lets us count distinct projects and see that a project came back next week; it cannot be turned back into a repository, and we cannot look you up from it. * **We do not try to work out who you work for.** No domain sniffing, no email inference, no matching a repo against a company. If you *want* us to know, [`reticle identify`](#telling-us-who-you-are) exists and you decide what it says. * **We do not record what you asked your agent to verify.** The `verification_completed` event knows that a verification happened and how it turned out. The prompt behind it is not something Reticle can see, and we do not reconstruct it. ## Feedback Reticle verifies apps for AI agents, and for a long time it had no way to hear when it got something wrong. An agent would hit a tool that misbehaved, work around it, and that knowledge would vanish at the end of the turn. The feedback channel is the fix — and because it is the only part of Reticle that transmits words someone wrote, it gets stricter rules than everything above. **It is never passive.** There is no code path that sends feedback on its own. A `feedback` event exists only because one of these happened: | Who | How | What it carries | | -------------------------------- | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Your agent | Calls the `reticle_feedback` MCP tool — after a failure, **or to ask for a feature** | What it wrote: an analysis, or a request with the goal behind it, what would improve, and how it works around the gap today. Plus the model it is running, which it tells us because MCP cannot | | You | Run `reticle feedback [--rating 1-5] [--bug] "your words"` | Your words and your rating | | Your agent, before Reticle works | Runs `reticle feedback --agent --kind "what happened"` | The same words, filed from the shell. This exists for the phase where there is no daemon and no MCP tools — a failed `init`, half-finished wiring — which is the failure we are otherwise never told about | Agents are instructed, in the tool description itself, never to include app source, secrets, or user data — to describe the failure in their own words instead. **It is redacted before it leaves your machine.** Emails, credentials in URLs, `Authorization` headers and API-key assignments, recognizable vendor tokens (`sk-…`, `ghp_…`, `AKIA…`, `xoxb-…`, JWTs), and home-directory paths are stripped and replaced with `[redacted]`. This runs client-side, in [`feedback.ts`](../packages/server/src/telemetry/feedback.ts), so you can read exactly what it removes rather than take our word for it. It is a safety net, not a guarantee — it cannot catch a secret that looks like an ordinary sentence, which is why the instruction above comes first. **It shows you what it sends.** `reticle feedback` prints the exact payload before transmitting, and names any redaction rule that fired. Alongside the report, a feedback event carries context about the environment it came from — never about you: | Field | Example | Why | | ---------------------- | --------------------------------------- | ------------------------------------------------------------------ | | `stack` / `stackMajor` | `next` / `15` | Which frameworks a bug actually affects. Major version only | | `runtime` | `web`, `electron`, `tauri` | Desktop bugs look nothing like browser bugs | | `engine` | `blink`, `gecko`, `webkit` | Coarse bucket, never the user-agent string | | `driver` | `cdp`, `sdk` | Whether Reticle drove the page or observed your own browser | | `client` | `claude-code`, `cursor` | The MCP client's own name from its handshake | | `mcpScope` | `user`, `project` | How Reticle is registered | | `kind` | `bug`, `gap`, `ambiguity`, `experience` | A defect, a blind spot, an undecidable verdict, or an overall take | Plus the `version`, `os`, and `ci` fields every event carries. No paths, no project name, no dependency list, no user-agent string. **It has its own off switch.** To keep the anonymous counters but never send free text: ```bash theme={null} RETICLE_FEEDBACK=0 ``` Every switch in [Your choices](#your-choices) disables feedback too. If telemetry is off, feedback is off — and `reticle feedback` will tell you so rather than silently discard what you wrote. ## Telling us who you are Everything above is anonymous, and stays that way unless you decide otherwise. If you want us to know who you are — to get support, to ask about an enterprise licence, or to be a design partner — there is one command, and running it is the only way it ever happens: ```bash theme={null} reticle identify --context company --company "Acme" --email you@acme.com ``` `--context` is the only required part, and `company | side_project | open_source | learning` is the whole vocabulary. You can say "this is a company" without naming it, or name it without leaving an email. Before it sends anything, it prints what it will send and one thing worth reading carefully: **the identity is linked to this machine's anonymous id, so identifying yourself also connects the anonymous usage already recorded from this machine to what you enter.** That is what makes it useful to us, and it is why you are told before choosing rather than after. **One place mentions it to you.** After you send feedback as a human — never as an agent, and never if you have already identified or already declined — the receipt prints one line offering this command, because a bug report we cannot reply to is a conversation that ends after one sentence. Your feedback has already been sent by then: the line is an offer, not a question, and nothing is gated on it. **No address is ever attached to the feedback itself** — it would put personal data on the anonymous stream, which is exactly what the rest of this page promises not to do. To undo it: ```bash theme={null} reticle identify --forget ``` That deletes the local file and stops any further sends. To have what was already sent removed, email [support@reticlehq.com](mailto:support@reticlehq.com). ## Where it goes Events are sent over HTTPS to [PostHog](https://posthog.com) (US cloud), a product-analytics service acting as our data processor, and are used only in aggregate (counts, retention curves, tool popularity). We do not sell or share this data. ## Your choices Telemetry is on by default and Reticle tells you so the first time it runs — once, in one line, with a pointer to this page. To see the current state at any time: ```bash theme={null} reticle telemetry status ``` Three ways to opt out, in whatever form fits your setup: | Method | Scope | | --------------------------- | ------------------------------------------------------------------------------ | | `reticle telemetry disable` | This machine, permanently (until `reticle telemetry enable`) | | `RETICLE_TELEMETRY=0` | Wherever the variable is set — handy for CI or a fleet-wide profile | | `DO_NOT_TRACK=1` | The [cross-tool convention](https://consoledonottrack.com) — Reticle honors it | Opting out changes nothing about how Reticle works. A failed or blocked telemetry send never delays, alters, or fails a command either — sends are best-effort and asynchronous by design. To also remove the locally stored random id, delete `~/.reticle/telemetry-id`. ## A note on data protection The data described above is designed not to identify you: the only identifier is a locally minted random UUID, the project reference is a one-way hash, and no personal data is collected. We collect it on the basis of our legitimate interest in understanding and improving Reticle, we minimize what is collected to the fields listed here, and we honor every opt-out signal above. If you believe something in this design falls short of that intent, please open an issue — that is a bug, and we will treat it as one. Any change to what is collected will be listed on this page and called out in the release notes of the version that introduces it. # Testing Source: https://reticle.mintlify.app/testing # Testing with `@reticlehq/test` — declarative, signal-bound specs Driving Reticle interactively is reconnaissance. To turn it into a **repeatable, CI-runnable** suite, write declarative specs with `@reticlehq/test`. Specs bind to **signals and testids — never DOM structure** — so they inherit Reticle's refactor-resistance. ```ts theme={null} import { reticleTest } from '@reticlehq/test'; reticleTest('add a task', async (t) => { await t.act('add-task', 'click'); await t.expectElement({ testid: 'task-list' }, 'visible'); }); reticleTest('ai chat edit', async (t) => { await t.fill('chat-input', 'Make the hook punchier'); await t.act('chat-send', 'click'); await t.expectNet('POST', '/chat-script', 200); await t.expectSignal('chat:edit-applied', { sections: ['hook'] }); }); ``` ## The test context `t` A thin, typed façade over Reticle's tools — it resolves testids → refs for you, so specs never touch refs or DOM: | Method | What it does | | ------------------------------------------------------------------------------------- | --------------------------------------------------------- | | `t.act(testid, action, args?)` | perform an action on a testid'd element | | `t.fill(testid, value)` | fill an input | | `t.actAndWait(testid, action, until)` | act, then block until a predicate holds | | `t.expectSignal(name, dataMatches?)` | assert an app signal fired (with optional data match) | | `t.expectNet(method, urlContains, status?)` | assert a network call happened | | `t.expectElement(query, state?)` / `t.expectText(contains)` / `t.expectAbsent(query)` | DOM assertions | | `t.expectNoConsoleErrors()` | assert the flow produced no console errors | | `t.state(storeOrRef)` | read a registered store / a component's state | | `t.clock.freeze() / advance(ms) / reset()` | deterministic time (toasts, debounces, auto-dismiss) | | `t.expectInputModeReal()` | guard: pass under real input, else **skip with a reason** | Any failed matcher throws with the structured evidence (near-miss, failure reason) so the runner reports *why*. ## Deterministic + honest * **`t.clock`** bakes `reticle_clock` into the spec, so time-gated UI (a 5s auto-dismiss, a 500ms hover dwell) is tested deterministically instead of racing real timers. * **`t.expectInputModeReal()`** — a hover/drag spec asserts native input is active; if it's running synthetic (no CDP), the spec is **skipped with a reason**, never silently passing on a no-op. Enable real input headless with `reticle drive` (see [usage §18](usage.md#18-real-input-mode--native-hover--drag)). ## Run a suite (headless, the same path CI uses) `bootSession` launches a headless real-input browser at your app and gives the runner a programmatic tool invoker (no MCP/stdio): ```ts theme={null} import { reticleTest, bootSession, runSpecs, createTestContext } from '@reticlehq/test'; // … reticleTest(...) registrations above … const booted = await bootSession({ driveUrl: 'http://localhost:4310', headless: true }); const { summary } = await runSpecs({ invoke: booted.invoke, now: () => Date.now(), buildContext: (invoke) => createTestContext(invoke, { sessionId: 'my-app' }), print: (line) => process.stdout.write(line + '\n'), }); await booted.close(); process.exit(summary.failed === 0 ? 0 : 1); ``` Each spec reports `pass` | `fail` (with evidence) | `skip` (with reason). For CI, emit JUnit: ```ts theme={null} import { toJUnitXml, writeJUnit } from '@reticlehq/test'; ``` ## Flows become specs `.reticle/` flows (see [Flows](flows.md)) can be executed directly as specs — replayed with their `expect`/`success` predicates and skipping `dynamic` (LLM-output) regions — so the recorded map and the suite can't drift apart: ```ts theme={null} import { flowsAsSpecs } from '@reticlehq/test'; // register one reticleTest per flow under .reticle/flows/ ``` ## Authoring tip: record → prune → commit You don't have to hand-write steps. Drive the flow once (or record it via the panel), let Reticle emit the program, trim it, and commit it as a spec — the regression test is a byproduct of testing, not separate work. # Token efficiency Source: https://reticle.mintlify.app/token-efficiency # Token efficiency: Reticle vs. a full-tree snapshot (Playwright MCP) Agent browser tools that feed the **whole accessibility tree** to the model every step get expensive fast. Playwright MCP's own ecosystem notes its snapshots *"can exceed 50,000 tokens on complex pages,"* with a *typical task \~114,000 tokens through MCP.* Reticle is built to ask **narrow questions** instead, so the per-interaction cost stays tiny. ## Head-to-head (measured, same page, same moment) Measured against the bench dashboard (`apps/bench-app`) **with a 1,000-item list rendered**, after login. Token estimate = characters ÷ 4. Reproduce with the benchmark harness — see `bench/README.md`. | Payload | Tokens | | ------------------------------------------------------------------------------ | ----------: | | **Playwright MCP** — with-refs snapshot (the real payload it sends every step) | **\~7,300** | | Playwright MCP — bare accessibility tree (what we measured directly) | \~6,856 | | Reticle — `snapshot` `full` (whole page, incl. all 1,000 items) | \~4,144 | | Reticle — `snapshot` `interactive` (actionable elements only) | \~110 | | Reticle — `snapshot` `status` (route / dialogs / counters) | \~31 | | Reticle — `query` one element | \~28 | | Reticle — `observe` (reaction after an action) | \~39 | | Reticle — `assert` verdict | \~33 | | **Reticle — a full verify loop** (`query` + `observe` + `assert`) | **\~100** | **Result on this page:** the common Reticle loop is **\~73× leaner** than Playwright MCP's per-step snapshot (100 vs \~7,300 tokens). The bare a11y tree we measured directly is 6,856; Playwright MCP's actual payload adds a `[ref=…]` to every node, pushing it to \~7,300. On the complex pages Playwright's ecosystem cites (50k+), the gap widens to **\~100–500×**. ## Diffed snapshots: pay once, then only for changes After the first snapshot, pass `reticle_snapshot({ diff: true })` to get back **only what changed** since your last look of the same scope/mode (`mode:delta` with added/removed lines, or `mode:unchanged`). A route change auto-resets to a full snapshot, so you never read a misleading cross-page diff. Measured on a representative 150-row dashboard (the shipped regression benchmark `packages/server/src/tools/snapshot-cost.test.ts`, char/4 proxy): | Payload | Tokens | | ---------------------------------- | --------: | | Full re-snapshot (150-row table) | **4,246** | | `diff:true` after a one-row change | **60** | | `diff:true` when nothing changed | **17** | **\~99% fewer tokens** to re-look after an action — and because a `delta` carries no stale full tree, it also removes the 60–80K-token stale-context buildup that makes long-running agents start hallucinating selectors that no longer exist. Every `reticle_snapshot`/`reticle_query` result also carries `cost:{ bytes, tokens }` (estimated) so you can **re-scope before reading** a large body (`mode:interactive`/`status`, a tighter `scope`, or a narrower `query`) instead of paying for it first. ## The other tax: tool schemas, paid on every request Per-payload leanness is only half the token story. Before an agent reads a single result, it pays for the tool SCHEMAS injected into its context on every request — and this is the metric the field now organises around. A filed issue measures Playwright MCP's default tool list at 14.4k tokens = 7.2% of a Claude Code context window, and Microsoft's own README steers coding agents to the CLI over its MCP on exactly these grounds. Measured live, all servers in one run, same tokenizer (`bench/harness/schema-tax.mjs`): | MCP server | tools | schema tokens | | ----------------------------------------- | ----: | ------------: | | **Reticle — the tool surface** | 18 | **\~4,930** | | Playwright MCP | 23 | 3,725 | | Chrome DevTools MCP | 29 | 5,116 | | Reticle — `RETICLE_ADVERTISE_ALL_TOOLS=1` | 48 | \~30,200 | There is one tool surface: the verify loop advertised directly, plus two meta-tools (`reticle_tools`, `reticle_run`) that reach every other tool on demand. Nothing is unreachable; the cold tail simply is not re-sent every turn. `RETICLE_ADVERTISE_ALL_TOOLS=1` advertises everything WITH output schemas — a verification switch for suites that call by name, not a mode to run agents in. It is roughly 7x the per-turn cost, which is why it is opt-in: measured, carrying output schemas on the default surface takes it from 18,183 to 41,117 bytes. The typed result object still travels as `structuredContent` either way; the default surface simply does not advertise the output schema, which an agent reading the `text` block never consumed. ## The honest version * **Full-tree vs full-tree, the gap is modest (\~1.8×):** Reticle `full` (4,144) vs Playwright's with-refs snapshot (\~7,300). Reticle collapses generic wrapper nodes, but both include every list item. If you force Reticle to dump the whole page each step, you don't save much. * **The savings come from *not needing* the full tree.** Playwright MCP's primary perception primitive is "return the accessibility tree"; Reticle's is "answer a specific question" (`query`/`assert`/`observe`/scoped or interactive `snapshot`). The win is architectural, not a cleverer serializer. * **Cost scales with interactive elements + what you look at, not total DOM.** The 1,000 list items cost \~0 in `interactive` mode because they aren't interactive. * **This is tool-output tokens only.** The agent's own reasoning tokens dominate either way — which is the point: keep observation cheap so the budget goes to thinking. ## Why it matters in practice A 20-step verification flow: * **Full-tree approach:** \~7,300 tokens × 20 ≈ **\~146,000 tokens** (and more on complex pages), plus a vision model if it also screenshots. * **Reticle:** \~100 tokens × 20 ≈ **\~2,000 tokens**, any model, deterministic. At scale (long flows, large dashboards, frequent re-runs for regression) that difference is the difference between "too expensive to run every change" and "run it on every edit." ## Method & caveats * One page, one tool, char/4 token proxy — directional, not a benchmark suite. Absolute numbers vary by page; the *ratio* is the point. * `_snapshotForAI()` (Playwright MCP's exact with-refs payload) was unavailable in the installed Playwright build, so we measured `body.ariaSnapshot()` — the same accessibility tree it serializes; the real MCP payload is equal or slightly larger (it adds `[ref=…]`). * Playwright MCP is excellent and Microsoft-backed; this is not a knock on it. It optimizes for cross-browser *driving*; Reticle optimizes for cheap, in-app *verification*. They can coexist (drive with one, assert with the other). Run it yourself: the benchmark harness in `bench/` (see `bench/README.md`), with the demo + api running. # Usage Source: https://reticle.mintlify.app/usage # Reticle — Complete Usage Guide The full reference and cookbook. If you haven't set up Reticle yet, start with [Getting Started](getting-started.md). **Contents** 1. [How Reticle helps you](#1-how-reticle-helps-you) 2. [Core concepts](#2-core-concepts) 3. [The tools — full reference](#3-the-tools--full-reference) 4. [The predicate DSL — full reference](#4-the-predicate-dsl--full-reference) 5. [Actions — full list](#5-actions--full-list) 6. [Snapshot modes & scoping](#6-snapshot-modes--scoping) 7. [Cookbook: real situations](#7-cookbook-real-situations) 8. [Regression: baselines & diff](#8-regression-baselines--diff) 9. [Recording a flow](#9-recording-a-flow) 10. [Autonomous exploration](#10-autonomous-exploration) 11. [Turning your test cases into agent checks](#11-turning-your-test-cases-into-agent-checks) 12. [Token discipline](#12-token-discipline) 13. [Best practices & gotchas](#13-best-practices--gotchas) 14. [FAQ](#14-faq) 15. [Security & privacy](#15-security--privacy) *** ## 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](token-efficiency.md)). * **Your manual QA becomes automated.** The checklist you never turned into Playwright tests? Your agent runs it now (see [§11](#11-turning-your-test-cases-into-agent-checks)). 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_snapshot` (what's on screen) or `reticle_query` (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 `snapshot` or `query`, then pass them to `act`/`inspect`. 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, not a vibe. **Sessions.** Each connected browser tab is a session (named via `reticle.connect({ session })`). With one tab open you never specify it; with several, pass `sessionId`. *** ## 3. The tools — full reference ### `reticle_sessions` List connected tabs. → `{ sessions: [{ sessionId, url, title, lastSeenMs, hidden, focused, throttled }] }`. `lastSeenMs` is the silence since the tab last reported (not time-since-connect); `throttled` is `true` when the tab is hidden or has gone quiet — a throttled tab silently no-ops timers/rAF/pointer. ### `reticle_snapshot` A semantic, accessibility-tree view of the page. * **args:** `mode?: 'full' | 'interactive' | 'status'` (default `full`), `scope?` (CSS selector or ref), `diff?: boolean`, `sessionId?`. * **returns:** `{ tree, status: { route, title, visibleDialogs }, 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](token-efficiency.md). * **`cost`** is an estimated size of the result — re-scope (`mode`/`scope`) before reading if large. ```jsonc theme={null} reticle_snapshot({ mode: "interactive" }) // - tab "Overview" (ref=e2) // - button "Add item" (ref=e5) // status: { route: "/dashboard", visibleDialogs: [] } reticle_snapshot({ diff: true }) // after an action — only the change set // { mode: "delta", delta: { added: ['- alert "Saved!"'], removed: [], addedCount: 1, removedCount: 0 } } ``` ### `reticle_query` Find elements (Testing-Library semantics). * **args:** `by: 'role'|'text'|'label'|'placeholder'|'testid'|'alt'`, `value`, `name?` (for role), `scope?`, `sessionId?`. * **returns:** `{ elements: [{ ref, role, name, value?, states, visible, text? }] }`. ```jsonc theme={null} reticle_query({ by: "role", value: "button", name: "Save" }) // → ref + descriptor ``` ### `reticle_inspect` 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, backgroundToken, offTheme, tokenCount }` (compliance vs the app's `:root` design tokens — `offTheme:true` flags an off-palette color) + `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_sequence` 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 }` (F2) 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_sequence` args:** `steps: [{ ref, action, args? }]`. → `{ since, dispatched, result }` where `result = { ok, count, effects: [...], steps: [...] }` (one `effect` per step; each step carries its own `dispatched`/`settled`/`settleReason`). * See [§5](#5-actions--full-list) for the action list. **Dispatch vs settle (F1).** The action is two phases: the **dispatch** (the synchronous click/fill — this 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. | top-level field | meaning | | --------------- | ------------------------------------------------------------------------------------ | | `dispatched` | the action dispatched without throwing (mirror of `effect.dispatched`) | | `settled` | a real animation frame flushed within the budget; `false` = the fallback timer fired | | `settleReason` | `"timeout"` when the fallback fired (throttled tab), else `null` | **`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. | field | meaning | | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `dispatched` | always `true` (if we couldn't dispatch, the tool throws instead) | | `targetMatched` | the ref still resolved to a connected element | | `visible` | element was visible at the start of the action | | `enabled` | element was not disabled / aria-disabled at the start | | `defaultPrevented` | a handler called `preventDefault()` on the primary cancelable event. Only meaningful for `click`/`dblclick`/`hover`/`fill`/`type`/`clear`/`press`/`upload`/`drag`; always `false` for non-cancelable events (`focus`/`blur`/`select`/`check`/`uncheck`/`submit`/`scrollIntoView`) | | `focusMoved` | `"->"` if `document.activeElement` changed, else `null` (body counts as `null`) | | `valueChanged` | `fill`/`type`/`clear` only: input value before !== after; otherwise `false` | | `domMutatedWithin` | count of MutationObserver records seen in the window | | `occluded` | `click`/`dblclick` only: the click point hit-tested to a *foreign* element (an overlay is on top). Synthetic dispatch still delivered the event, but **a real user could not click it** — treat the target as visually blocked. `false` when not click-like or not hit-testable | | `occludedBy` | the ref of the element actually on top at the click point when `occluded`, else `null` | | `scrolledIntoView` | `click`/`dblclick` only: the target was off-viewport, so Reticle scrolled it into view before dispatch | 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?"** ```ts theme={null} const { result } = reticle_act({ ref: saveBtn, action: 'click' }); if (result.effect.defaultPrevented) { // a handler blocked the default — the click was swallowed } else if (result.effect.domMutatedWithin === 0) { // dispatched cleanly but the app rendered nothing — likely a dead control } ``` ### `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`, `action`, `args?`, `until: `, `timeout_ms?` (default 4000; 0 = evaluate once), `refuseWhenThrottled?`, `sessionId?`. * **returns:** `{ effect, verdict, trace, session, warning? }` — `effect` is the action result (`{ ok, ref, action }`), `verdict` is `{ pass, evidence?, failureReason? }`, `trace` is the reaction report of everything the app did after the action, and `session` (F2) 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_wait_for` Block until a predicate holds (or time out). Looks both backward (recent buffer) and forward. * **args:** `predicate`, `timeout_ms?` (default 4000), `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/animation event buffered *before* the action can never satisfy the predicate (the report's "validation 68 == 68 was a lie" footgun). 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?`, `sessionId?`. * Same `since` default as `reticle_wait_for`: 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?, 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. * **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?`, `sessionId?`. * **returns:** `{ mismatches, compared, note? }`. * 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_network` / `reticle_console` / `reticle_animations` Fast targeted lookups without a full timeline. * `reticle_network({ since?, method?, urlContains?, status? })` → `{ calls }` * `reticle_console({ level?, since? })` → `{ logs }` * `reticle_animations()` → 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_capabilities({ sessionId? })` → `{ testids, signals, stores, flows }` `reticle_sessions` 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_domain({})` → `{ 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_state` Read live framework/store state directly instead of inferring it from the DOM — [§17](#17-evidence-of-effect-actawait-state-capabilities-replay). * `reticle_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_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_state`: ```ts theme={null} // app entry — MUST run before react-dom loads, so import it FIRST (React reads the devtools hook // at renderer-inject time). It augments a real React DevTools hook if present; host-safe (no-ops on // any failure, never breaks the app). import { installRenderMeter } from '@reticlehq/react'; installRenderMeter(); ``` ```jsonc theme={null} reticle_state({ store: "__reticle_renders", path: "commits" }) // → total React commits (monotonic) // read it, do an action (or wait a window), read again → the delta is the commit count for that span. ``` 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) — [§16](#16-presenter-mode-narration--fake-clock-watch--control). ### `reticle_baseline {action:"save"}` / `reticle_baseline {action:"list"}` / `reticle_baseline {action:"diff"}` Regression detection — [§8](#8-regression-baselines--diff). ### `reticle_record {action:"start"}` / `reticle_record {action:"stop"}` / `reticle_replay` Capture a flow's reaction report and compile it into a replayable program — [§9](#9-recording-a-flow). `reticle_record {action:"stop"}` also returns a `cost:{ events, bytes }` hint alongside the reaction report so you can gauge the recording's size. ### `reticle_explore` List interactive elements + console-error count for autonomous exploration — [§10](#10-autonomous-exploration). ### Flows, recorder & self-healing (`.reticle/`) `reticle_contract_save`, `reticle_flow_save` / `reticle_flow_save_recorded` / `reticle_flow {action:"list"}` / `reticle_flow {action:"load"}` / `reticle_flow_replay` / `reticle_flow_verify`, `reticle_flow_heal`, `reticle_annotate` — record once, replay forever (anchored on testid/signal — or an auto-derived component/source anchor when there's no testid), with legible drift + self-heal. Full guide: [Flows, the recorder & self-healing](flows.md). * **`reticle_flow_verify({ names?, sessionId? })`** — the regression-suite call: 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_flow_verify` 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](human-control.md). ### `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: ``` reticle_session {action:"review"}({ sessionId }) → { marks: [{ id: "m1", note: "this button is misaligned", label: "button \"Pay\"", source: { file: "src/Checkout.tsx", line: 42 }, fix: "Open src/Checkout.tsx:42 and fix: this button is misaligned. Then reticle_session {action:"review"} { resolve: \"m1\" }" }], pendingCount: 1 } ``` 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_sessions` also reports `pendingMarks` so you notice flagged bugs during normal orientation. ### `reticle_network_mock` — stub the network for error-state testing (driven mode) On a page Reticle drives (`reticle drive`), make a request return a 500, force it offline, or delay it — so testing error/edge states is one declared rule, no backend changes: ``` reticle_network_mock({ mocks: [{ urlContains: "/api/pay", method: "POST", status: 500 }] }) → { applied: true, count: 1 } // now the checkout POST returns 500 — verify the failure UI reticle_network_mock({ mocks: [{ urlContains: "/api/feed", abort: true }] }) // simulate offline reticle_network_mock({ clear: true }) // turn mocking off ``` First matching rule wins (`urlContains` + optional case-insensitive `method`). Needs a driven browser; without one it returns a `recommendation` pointing at `reticle drive`. ### `reticle_viewport` — reproducible visual baselines (driven mode) Pin the driven page to a fixed viewport so a screenshot baseline is reproducible across machines: ``` reticle_viewport({ width: 1280, height: 800 }) // set once, before reticle_screenshot / reticle_visual_diff → { applied: true, width: 1280, height: 800 } ``` This is one of three knobs for **CI-stable visual regression** — set them together: 1. **`reticle_viewport({ width, height })`** — same dimensions on every machine. 2. **`reticle_clock({ freeze: true })`** — kill animation/time jitter so the pixels are stable. 3. **`reticle_visual_diff({ baseline, masks: [{ x, y, width, height }] })`** — neutralize volatile regions (clocks, avatars, ids) so only real changes fail. *** ## 4. The predicate DSL — full reference A **predicate** declares what should be true. `reticle_assert` / `reticle_wait_for` evaluate it against the live DOM + the event buffer. ### Leaf predicates ```jsonc theme={null} // An element exists / is in a state { "kind": "element", "query": { "role": "dialog", "name": "Confirm" }, "state": "visible" } // query supports: role, name, text, label, placeholder, testid, alt, scope // state: visible | hidden | enabled | disabled | checked | expanded | focused | present // add "absent": true to assert it is NOT there (regression / removal) // Visible text anywhere (optionally scoped via an element query instead) { "kind": "text", "contains": "Saved successfully", "visible": true } // A network call happened { "kind": "net", "method": "POST", "urlContains": "/api/order", "status": 200, "since": 1820 } // Navigation { "kind": "route", "pathname": "/success" } // or: "contains": "/success" // Console / errors { "kind": "console", "level": "error", "absent": true } // "no errors during this flow" // Animation { "kind": "animation", "name": "dialog-in", "completed": true } // An app-emitted signal (webhook/websocket/store change you surfaced via reticle.signal) { "kind": "signal", "name": "webhook:received", "dataMatches": { "provider": "stripe", "id": "*" } } // A registered store's VALUE — the source of truth no DOM/network read can reach. Walks a dot-path // (numeric array indices) and matches `equals`: a literal, omitted = presence, or a // { $gte | $lte | $gt | $lt | $contains | $length } operator pattern. Catches a UI-vs-store desync // (a deploy that only LOOKS shipped) deterministically, in one call — no LLM, no DOM scraping. { "kind": "state", "store": "app", "path": "deployments.0.status", "equals": "live" } ``` 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 ```jsonc theme={null} { "allOf": [ , , … ] } // every one must hold { "anyOf": [ , … ] } // at least one { "not": } ``` ### Timing * `timeout_ms` (on `assert`/`wait_for`): wait up to N ms for it to become true. * `since` (on `net`/`console` leaves): only consider events after this cursor (from `act`). `dataMatches` uses shallow JSON matching; `*` means "present, any value". *** ## 5. Actions — full list `reticle_act({ ref, action, args })`: | action | args | notes | | -------------------- | --------------------------- | ------------------------------------------------------------- | | `click` / `dblclick` | — | dispatches a real click | | `hover` | — | `mouseover`+`mouseenter` (triggers JS hover state) | | `focus` / `blur` | — | | | `fill` | `{ value }` | sets value via React-safe native setter + `input`/`change` | | `type` | `{ text }` | appends to current value | | `clear` | — | empties an input | | `select` | `{ value }` | `` | | `drag` | `{ toRef }` | pointer-based drag (dnd-kit / rbd) + HTML5 DnD | | `webmcp` | `{ tool, params }` | calls a `navigator.modelContext` tool if the site exposes one | *** ## 6. Snapshot modes & scoping `reticle_snapshot` has three modes — pick the cheapest that answers your question: * **`status`** (\~30 tokens) — route, visible dialogs, counters. "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 — 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. *** ## 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" ```jsonc theme={null} const { since } = reticle_act({ ref: iconBtn, action: "click" }) reticle_assert({ timeout_ms: 2000, predicate: { allOf: [ { kind: "element", query: { role: "dialog" }, state: "visible" }, { kind: "console", level: "error", absent: true } ]}}) ``` ### "I changed an API call — did it fire correctly and update the UI?" ```jsonc theme={null} const { since } = reticle_act({ ref: saveBtn, action: "click" }) reticle_assert({ timeout_ms: 3000, predicate: { allOf: [ { kind: "net", method: "PUT", urlContains: "/api/profile", status: 200, since }, { kind: "text", contains: "Saved", visible: true } ]}}) ``` ### "I clicked a button and it should add an element on another page/section" Act in section A, navigate to B, assert there: ```jsonc theme={null} reticle_act({ ref: notifyBtn, action: "click" }) // in "Items" reticle_act({ ref: notificationsTab, action: "click" }) // go to "Notifications" reticle_assert({ timeout_ms: 2000, predicate: { kind: "text", contains: "New item queued", visible: true } }) ``` ### "Data shows up only after \~30s (eventual consistency) — how to refresh and see it" ```jsonc theme={null} const { since } = reticle_act({ ref: addBtn, action: "click" }) reticle_assert({ predicate: { kind: "net", urlContains: "/api/items", status: 202, since } }) // accepted reticle_assert({ predicate: { kind: "element", query: { text: name, scope: "[data-testid=item-list]" }, absent: true } }) // not yet // …later: click your Refresh button, then wait for it… reticle_act({ ref: refreshBtn, action: "click" }) reticle_wait_for({ timeout_ms: 5000, predicate: { kind: "element", query: { text: name, scope: "[data-testid=item-list]" }, state: "visible" } }) ``` ### "The list has 100s/1000s of rows — was my item actually added?" Don't scroll and eyeball — query finds it regardless of position: ```jsonc theme={null} reticle_assert({ timeout_ms: 3000, predicate: { kind: "element", query: { text: "Invoice #4821", scope: "[data-testid=item-list]" }, state: "visible" } }) ``` > Note: if your list is **virtualized** (react-window/virtuoso), off-screen rows aren't in the DOM yet — scroll-to-find support is on the roadmap; for now scroll the container or assert against the data via an `reticle.signal`. ### "Login form — does it actually authorize?" ```jsonc theme={null} reticle_act({ ref: emailRef, action: "fill", args: { value: "admin@acme.com" } }) reticle_act({ ref: pwRef, action: "fill", args: { value: "•••••••" } }) const { since } = reticle_act({ ref: submitRef, action: "click" }) reticle_assert({ timeout_ms: 3000, predicate: { allOf: [ { kind: "net", method: "POST", urlContains: "/api/login", status: 200, since }, { kind: "element", query: { role: "heading", name: "Dashboard" }, state: "visible" } ]}}) // And the failure path: reticle_assert({ predicate: { allOf: [ { kind: "net", urlContains: "/api/login", status: 401 }, { kind: "element", query: { role: "alert" }, state: "visible" } ]}}) ``` ### "Make sure there are NO console errors" ```jsonc theme={null} reticle_assert({ predicate: { kind: "console", level: "error", absent: true } }) ``` ### "A real LLM call generates a script — is it happening and rendering?" ```jsonc theme={null} const { since } = reticle_act({ ref: generateBtn, action: "click" }) reticle_assert({ timeout_ms: 15000, predicate: { allOf: [ { kind: "net", method: "POST", urlContains: "/api/generate", status: 200, since }, { kind: "element", query: { testid: "script-output" }, state: "visible" } ]}}) ``` ### "Upload a file → it calls an LLM → a modal shows a score" ```jsonc theme={null} reticle_act({ ref: fileInput, action: "upload", args: { name: "pitch.mp4", type: "video/mp4" } }) const { since } = reticle_act({ ref: analyzeBtn, action: "click" }) reticle_assert({ timeout_ms: 15000, predicate: { allOf: [ { kind: "net", method: "POST", urlContains: "/api/score", status: 200, since }, { kind: "element", query: { role: "dialog", name: "Score result" }, state: "visible" }, { kind: "text", contains: "/ 100", visible: true } ]}}) ``` ### "A button's color should change on hover" ```jsonc theme={null} const before = reticle_inspect({ ref }).styles.backgroundColor reticle_act({ ref, action: "hover" }) const after = reticle_inspect({ ref }).styles.backgroundColor // assert before !== after ``` > Pure CSS `:hover` styling needs a real pointer; drive hover effects from JS state (or use a Playwright real-hover) if you need pixel-exact `:hover`. Reticle reads 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: ```ts theme={null} // in your app reticle.signal('webhook:received', { provider: 'stripe', event: 'payment_intent.succeeded' }); reticle.state('cart', { items: 3 }); // Advertise your testable surface at init so the agent learns it without reading source. // Call this once at module load (before connect); it merges idempotently across HMR reloads. reticle.describe({ testids: ['cart-badge', 'toast'], signals: ['webhook:received'], stores: ['cart'], flows: [{ name: 'checkout', steps: ['fill address', 'pay', 'see confirmation'] }], }); ``` The agent reads this back with `reticle_capabilities()` — see [§3](#3-tool-reference). ```jsonc theme={null} reticle_assert({ timeout_ms: 30000, predicate: { kind: "signal", name: "webhook:received", dataMatches: { provider: "stripe" } } }) ``` #### Keeping signals from drifting (lint) Signals only help if you actually emit one whenever user-visible state changes. The `@reticlehq/eslint-plugin` package ships one rule, `reticle/require-signal-on-mutation`, that flags any function which 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. ```js theme={null} // eslint.config.mjs import reticle from '@reticlehq/eslint-plugin'; export default [ { plugins: { reticle }, rules: { 'reticle/require-signal-on-mutation': [ 'error', { mutators: ['set', 'reorderSections', 'addSection'], signalCallee: 'reticleSignal' }, ], }, }, ]; ``` `mutators` lists the callee names that change state; `signalCallee` (default `['reticleSignal', 'signal']`) is the name that counts as firing a signal. See [`packages/eslint-plugin/README.md`](../packages/eslint-plugin/README.md) for scoping and matching details. *** ## 8. Regression: baselines & diff The "did anything silently break/disappear?" workflow. ```jsonc theme={null} // after you've confirmed a screen is good: reticle_baseline {action:"save"}({ name: "checkout-ok" }) // later, after a change: reticle_baseline {action:"diff"}({ baseline: "checkout-ok" }) // → { removed: ["- button \"Export\""], added: ["- alert \"Card declined\""], // consoleErrors: 2, routeChanged: false } ``` `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_baseline {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: ```jsonc theme={null} reticle_viewport({ width: 1280, height: 800 }) // 1. same size on every machine reticle_clock({ freeze: true }) // 2. no animation/time jitter reticle_screenshot({ name: "checkout-ok" }) // capture the baseline // …later, after a change, at the same viewport + frozen clock: reticle_visual_diff({ baseline: "checkout-ok", masks: [{ x: 0, y: 0, width: 200, height: 24 }] }) // → { matched: false, changedPixels, ratio, region, diffPath } // 3. masks ignore volatile regions ``` 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. ```jsonc theme={null} reticle_record {action:"start"}({ recordingName: "checkout" }) // …agent performs the flow (reticle_act / reticle_act_sequence)… reticle_record {action:"stop"}({ recordingName: "checkout" }) // → { // recordingName, // program: { version, steps: [{ tool, args: { by:"testid", value, action, args }, stable }] }, // events: [...ordered timeline...], // summary: { network, domAdded, … }, // warning? // present when some steps could not be bound to a testid // } ``` `reticle_record {action:"stop"}` returns a compiled, replayable `program`: the agent's `reticle_act` / `reticle_act_sequence` invocations captured during the span, with each ref normalized to its element's `data-testid` where resolvable. Re-run it later: ```jsonc theme={null} reticle_replay({ recordingName: "checkout" }) // re-resolves each step by testid and re-runs the actions in order // → { recordingName, ok, steps: [{ tool, ok, error?, note? }] } // stops at the first failure ``` **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_record {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: ```jsonc theme={null} reticle_explore({ scope: "main" }) // → { interactive: [ { ref, desc }, … ], consoleErrors, hint } ``` 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: | Test case (English) | Reticle check | | ---------------------------------------------------- | ------------------------------------------------------------------ | | Login with valid creds lands on the dashboard | `allOf[ net /api/login 200, element heading "Dashboard" visible ]` | | Submitting the form shows a success toast | `text "Saved" visible` (+ `net … 200`) | | Deleting an item removes it from the list | `element {text, scope:list}` `absent: true` | | No console errors on the checkout page | `console level:error absent:true` | | Export button visible for admins, hidden for viewers | `element {role:button, name:Export}` `visible` / `absent` | | Clicking a row opens the detail drawer | `element {role:dialog}` `visible` | 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 the sweet spot: the **manual cases you never automated** become things the agent runs 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](token-efficiency.md)), but keep it that way: * Prefer **`reticle_query` + `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 `role`s, labels, and `data-testid`s 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` (F2).** 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. * **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?** No — keep `reticle.connect()` behind a dev guard. The SDK is side-effect-free and tree-shakes out of prod builds. **Do I have to change my components?** No, for basic look/act/observe. You'll get better results by adding `data-testid`s 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. React, Next.js, Remix and Astro each have an app and a CI gate. SvelteKit is wired end-to-end — `reticle init` writes the client hook and the Vite plugin, and the plugin stamps `data-reticle-source` into `.svelte` components so verdicts carry `file:line` — but there is still no SvelteKit app in CI, so it is unverified rather than supported. Vue has a Pinia store adapter and nothing else: no detection, no `.vue` stamping, no gate. See [what Svelte support is and is not](getting-started.md#what-svelte-support-is-and-what-it-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 the README comparison. **Multiple tabs/apps?** Each is a session; pass `sessionId` to any tool when more than one is connected (`reticle_sessions` lists them). *** ## 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.** Baselines/recordings are local. The CLI sends anonymous, opt-out usage metrics only (random id + event names — no code, no PII; see [telemetry](telemetry.md)); opt out with `reticle telemetry disable`, `RETICLE_TELEMETRY=0`, or `DO_NOT_TRACK=1`. Feedback you or your agent deliberately send (`reticle feedback` / `reticle_feedback`) is the only free text that ever leaves the machine — never passive, redacted first, and separately disabled with `RETICLE_FEEDBACK=0`. * **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: ```ts theme={null} reticle.connect({ redact: { keys: ['licenceKey', /^partner[-_]?code$/i], // also redact these allow: ['designToken'], // stop redacting this false positive }, }); ``` * **`keys`** adds to the rule. A string matches a key name **exactly**, case-insensitively — `'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 — 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: | | Applies in the page | Applies on the driven path | | ----------------------- | :-----------------: | :------------------------: | | `keys` — plain strings | ✅ | ✅ | | `keys` — RegExp entries | ✅ | ❌ | | `allow` | ✅ | ❌ | 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: ```ts theme={null} reticle.connect({ session: 'my-app', present: true, pace: 450 }); ``` 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 (Claude) can crash, disconnect, or simply forget to call `reticle_session {action:"end"}` — and a backgrounded tab's own timers are throttled by the browser. So **the Node server owns liveness, not the browser tab:** * **Agent goes idle / forgets to end** → a server-side reaper (immune to tab throttling) ends the session after `idleEndMs` of no agent commands and pushes the end 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({ idleEndMs })` — it updates both the browser timer and the server reaper. 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*: ```jsonc theme={null} reticle_session {action:"narrate"}({ text: "Adding a beat, then checking the section count goes up" }) ``` 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: ```jsonc theme={null} reticle_clock({ freeze: true }) // freeze app timers (Date.now/setTimeout/setInterval) reticle_act({ ref: e9, action: "click" }) reticle_clock({ advanceMs: 5000 }) // jump 5s — the auto-dismiss fires now, deterministically reticle_assert({ predicate: { kind: "element", query: { role: "alert" }, absent: true } }) reticle_clock({ reset: true }) // restore real timers ``` 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 — 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 `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) ```jsonc theme={null} { "kind": "signal", "name": "chat:edit-applied", "dataMatches": { "count": { "$gte": 1 }, "sections": { "$contains": "hook" } }, } // operators: $gte $lte $gt $lt $contains (array/substring) $length ; "*" = present ``` 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`: ```jsonc theme={null} { since, dispatched, settled, settleReason, result: { ok: true, ref, action, dispatched, settled, settleReason, testid, effect: { dispatched, targetMatched, visible, enabled, defaultPrevented, focusMoved: "e11->e12"|null, valueChanged, domMutatedWithin } } } ``` `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 ```jsonc theme={null} reticle_act_and_wait({ ref, action, args?, until: , timeout_ms }) // → { effect, verdict: { pass, evidence, failureReason? }, trace: } ``` 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_state` — read live framework/store state No need to broadcast a signal for every fact. Register stores in your app: ```ts theme={null} import { registerStore } from '@reticlehq/react'; registerStore('workspace', useWorkspace); // pass the store itself → auto STATE_CHANGE diffs ``` **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: ```ts theme={null} import { tanstackQueryStore, jotaiStore, xstateStore, valtioStore, mobxStore, svelteStore, piniaStore, recoilStore, } from '@reticlehq/browser'; registerStore('queries', tanstackQueryStore(queryClient)); // TanStack Query registerStore('app', jotaiStore(getDefaultStore(), { cart, user })); // Jotai (name the atoms) registerStore('machine', xstateStore(actor)); // XState registerStore('app', valtioStore(state, snapshot, subscribe)); // Valtio registerStore('app', mobxStore(store, toJS, reaction)); // MobX registerStore('cart', svelteStore(cartStore)); // Svelte store — `{ subscribe }` is the whole contract registerStore('cart', piniaStore(useCartStore())); // Pinia (Vue) ``` **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: ```tsx theme={null} import { snapshot_UNSTABLE, useRecoilTransactionObserver_UNSTABLE } from 'recoil'; function ReticleRecoilBridge() { const latest = useRef(snapshot_UNSTABLE()); const listeners = useRef(new Set<() => void>()).current; useRecoilTransactionObserver_UNSTABLE(({ snapshot }) => { latest.current = snapshot; for (const l of listeners) l(); }); useEffect(() => { registerStore( 'recoil', recoilStore( { cart: cartAtom, user: userAtom }, () => latest.current, (l) => { listeners.add(l); return () => listeners.delete(l); }, ), ); }, []); return null; } ``` 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` and `dataUpdatedAt` per query key, 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: ```tsx theme={null} import { useReticleStore } from '@reticlehq/react/store'; function CartProvider({ children }) { const [cart, dispatch] = useReducer(cartReducer, initial); useReticleStore('cart', cart); // one line — the agent can now read and assert on cart return {children}; } ``` ```jsonc theme={null} reticle_state({ store: "workspace" }) // → { stores: { workspace: {…} } } reticle_state({ ref: "e9" }) // → { component: { ok: true, component, hooks } } or { component: { ok: false, reason: "component-state-unavailable" } } // `hooks` carries the hook VALUES only (state / ref / memo). React effect entries — chained, // null-filled fiber internals with nothing to act on — are dropped, and when any were, the read // says so: component.truncation = { droppedItems, note }. // Scope a large store instead of paying for the whole thing: reticle_state({ store: "workspace", path: "captionCache.v3" }) // → { found: true, value: {…} } reticle_state({ store: "workspace", depth: 1 }) // → top-level keys, deeper values collapsed to "{…N keys}" reticle_state({ store: "workspace", path: "nope" }) // → { found: false, availableKeys: ["captionCache", "version", …] } ``` 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. ```jsonc theme={null} reticle_query({ by: "testid", value: "revenue-chart" }) // → { elements: [{ ref: "e12", role: "img", source: "src/Chart.tsx:34", // chart: [{ kind: "non-finite-coordinates", tag: "polyline", attr: "points", // sample: "0,10 5,NaN 10,20" }] }] } ``` `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: ```ts theme={null} import { canvasChartData } from '@reticlehq/browser'; canvasChartData(canvasEl, window); // → { library: "chartjs", data: { datasets: [...] } } ``` ### `reticle_capabilities` — the app's testable surface Declare it once so the agent learns the surface without reading source: ```ts theme={null} import { registerCapabilities } from '@reticlehq/react'; registerCapabilities({ testids: [...], signals: [...], stores: [...], flows: [...] }); ``` ```jsonc theme={null} reticle_capabilities() // → { testids, signals, stores, flows } ``` ### `reticle_replay` — recordings become re-runnable programs `reticle_record {action:"start"}` → drive the flow → `reticle_record {action:"stop"}` returns a **compiled program** (steps bound to testids/signals, not volatile refs). `reticle_replay({ recordingName })` re-executes it — 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 + a `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: ```jsonc theme={null} { since, dispatched, settled, inputMode: "synthetic" | "real", inputModeReason?, result, session, warning? } ``` 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` — 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: | `inputModeReason` | meaning / fix | | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | `page-not-correlated-to-a-cdp-target` | no CDP page matches the session URL — usually a fresh tab or a CDP target that isn't this page | | `element-not-locatable` | the element had no box (off-screen / stale ref) — `scrollIntoView` first | | `drag-target-unresolved` | a drag's `toRef` was missing or not locatable | | `provider-declined` / `provider-error` | the CDP provider declined or threw (the latter also sets `warning`) | | `not-a-pointer-action` | `fill`/`type`/etc. — these are always synthetic by design | | `synthetic-click-preferred` | a `click`/`dblclick` ran the occlusion-honest synthetic path by default — pass `args:{ native:true }` to force a trusted native click | (No `inputModeReason` is set when real input simply isn't configured — synthetic is the expected default there.) ### 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 }` — clicks default to synthetic), and reports `inputMode: "real"`. 1. Launch your browser with remote debugging: ```bash theme={null} # Chrome/Chromium google-chrome --remote-debugging-port=9222 http://localhost:3000 ``` 2. Tell the Reticle server where it is, via the MCP config `env`: ```jsonc theme={null} // .mcp.json { "mcpServers": { "reticle": { "command": "npx", "args": ["@reticlehq/server", "mcp"], "env": { "RETICLE_CDP_URL": "http://localhost:9222" }, }, }, } ``` 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"`, the reported session URL isn't correlated to a CDP target and real input silently falls back to synthetic.) > **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 sits **bottom-center**, 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_sessions` and every act/assert result carry a `session.recommendation` saying so and pointing to `reticle drive ` for a guaranteed scriptable context — refocus the tab, or use `reticle drive`.