Skip to main content
The loop is look → act → observe → assert. Verdicts come from reticle_act_and_wait, reticle_assert, reticle_act { steps } when a step declares expect, and reticle_verify (change/flows). Everything else moves or reads the app and proves nothing. Only verified: "yes" is a pass: unknown means Reticle could not tell, and no-fault means nothing was declared to prove. Reach for reticle_act_and_wait({ ref, action, until }) first: it names the expected consequence before the action, which is the difference between a check and a rationalisation. 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.

The core loop: look → act → observe → assert

Verdicts come from reticle_act_and_wait, reticle_assert, reticle_act { steps } when a step declares expect, and reticle_verify (change/flows). Everything else moves or reads the app and proves nothing. Only verified: "yes" is a pass: unknown means Reticle could not tell, and no-fault means nothing was declared to prove. A drive ending without a verdict 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. Report unknown 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 reticle_look { action: "page" } or { action: "find" }; 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 (settle: nothing new happening for a short quiet window, measured as 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_assert({ action: "wait", 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: An element predicate CHECKS value and text. They are not folded into the locator and ignored.
Two consequences worth knowing:
  • You can assert what a field contains. Do not read the value with reticle_look { action: "find" } and compare it in prose: that produces no verdict and does not count as verification.
  • { role, text } checks the text, so it does not match every button with that role.
  • A text predicate reads rendered text, not a field’s value. Text typed into an <input> or a <textarea> is not on the page as text, so { kind: "text", contains } does not see it. Assert it with value on an element predicate, as above.
Fields that nothing can check are refused rather than ignored: by without a value, and label, placeholder, testid or component when a higher-precedence field already selected the element. alt is the one that can still be checked beside another locator: on an image it is compared with the image’s alt attribute (so alt: "" passes on a decorative image but not on one missing its alt), and on anything that is not an image it is refused, with a hint to use name. An element query is a first-match dispatch, not a conjunction. Combinators take predicates, not a bare array. This is the shape most often got wrong, and it is the one that produces no verdict at all:
Each child reports its own evidence, in order:
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:
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. Read the recommendation: it differs by cause, and the two causes have different fixes. throttled is hidden || stale, and it is not a verdict that the tab cannot be driven. A tab that is hidden does clamp timers and rAF, so an action can land on a page that never advances. A tab that is merely stale, meaning no health heartbeat arrived inside the window, is usually just a quiet page, and is very often still driveable; a session flagged this way has taken a sign-in and two clean net-grade verdicts with no retries. Try the drive before you conclude anything, and reach for refuseWhenThrottled: true when the work is timing-sensitive (animations, debounces, gestures), which is what actually degrades. Leasing is not a free upgrade. reticle_lease gives a guaranteed scriptable context, and it is a separate one: the HUD lives in the human’s tab, so everything you do in a lease is invisible to the person watching. Taking that advice with someone sitting in front of the app makes the product look broken while it works correctly. Working alone, lease and do not think about it; with an audience, spend a slower tab to stay visible.
Store reads (reticle_look { action: "state" }) are the reliable path; the DOM can lie (optimistic UI, stale render).
Truncation and coverage are declared, never silent. A big reticle_look { action: "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 }], one of 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_look { action: "find" } → { route, presentTestids, knownEmptyState }; reticle_observe { action: "network" } → { totalInWindow, present[] } (what DID fire); reticle_observe { action: "console" } → { totalInWindow, byLevel } (so “0 errors” ≠ “silent page”); reticle_look { action: "state" } lists storeNames when a store isn’t found. Read the hint before assuming “not there.” Scope big stores with reticle_look({ action: "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 ten tools you are shown. reticle_run is one of them: it calls any registered tool by name, advertised or not. reticle_navigate · reticle_act · reticle_act_and_wait · reticle_assert · reticle_look · reticle_observe · reticle_session · reticle_verify · reticle_tools · reticle_run Most of what used to be a tool of its own is now an action on one of them: reticle_look { action: "page" } (was reticle_snapshot) · { action: "find" } (reticle_query) · { action: "element" } (reticle_inspect, DOM node → src/App.tsx:104) · { action: "state" } (reticle_state) · reticle_observe { action: "network" } · { action: "console" } · reticle_assert { action: "wait" } (reticle_wait_for) · reticle_act { steps: [...] } (reticle_act_sequence) · reticle_session { action: "list" } (reticle_sessions) · { action: "feedback" } (tell the maintainers what is missing) · {action:"yield"} the moment you stop driving · {action:"resume"} after a human pause. Call an old name and you are told the new one. reticle_tools prints the live surface and every mapping.
  • reticle_verify: the tool that checks the whole app or the whole change in one call. { action:"crawl" } drives every reachable control itself and reports the whole fault set in one call (destructive: it really clicks). { action:"flows" } replays every saved flow with no model in the loop. Reach for it when you think you are done: every other tool here answers “here is more to look at”.
Not on this surface at all, but one hop away: reticle_run { tool, args } calls any registered tool by name, advertised or not, and reticle_tools { names: [...] } loads its arguments. That reaches reticle_capabilities (the app’s whole testable surface in one call), reticle_domain (learn the app + gaps), reticle_run { tool: "reticle_baseline", args: { action: "diff" } }, reticle_project (run history), reticle_screenshot, reticle_visual_diff, reticle_storage, reticle_network_mock and reticle_clock. A daemon started with RETICLE_ADVERTISE_ALL_TOOLS=1 advertises some of these directly (reticle_capabilities, reticle_screenshot, reticle_visual_diff, reticle_storage, reticle_network_mock, reticle_clock, plus reticle_record, reticle_flow_save, reticle_flow_replay, reticle_intent and reticle_context); the rest stay behind reticle_run. It also switches to the unmerged spelling: reticle_snapshot/reticle_query/reticle_inspect/reticle_state replace reticle_look, reticle_network/reticle_console replace reticle_observe { action }, reticle_wait_for replaces reticle_assert { action: "wait" }, reticle_sessions/reticle_feedback replace reticle_session list/feedback, and reticle_act_sequence replaces reticle_act { steps }. Examples on this page use the default spelling. What the wider surface adds, through reticle_run (a few are also advertised directly with RETICLE_ADVERTISE_ALL_TOOLS=1): record/replay a journey (reticle_run { tool: "reticle_record", args: { action: "start" } }/stop, then { action: "replay" }), persist a self-healing golden flow (reticle_flow_save* / reticle_flow_replay / reticle_verify { action: "heal" }), compile annotations (reticle_annotate), explore autonomously (reticle_look { action: "page", mode: "interactive" } lists controls; reticle_verify { action: "crawl" } is now advertised directly, clicks them all and reports anomalies, and is destructive), reveal a virtualized off-screen row (reticle_scroll_to, for when reticle_look { action: "find" } 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, or forwardTo another origin, driven or leased), 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)

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/<name>.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 <url> / RETICLE_CDP_URL) or a leased tab (reticle_lease); without one they return { ok:false, reason:"no-visual-provider" } (the always-on SDK ships no screenshotter).

Fields worth reading that you may not know are there

Recent additions, each of which answers a question agents were previously asking a second call to resolve: When a verdict comes back unsettled, it now names what it was waiting for and what the window actually held. Read those before retrying: an unknown that explains itself is a retry with a better until, and one that does not is a dead end. If the port is taken, reticle stop --force frees it and leaves your own MCP proxy alone. Do not reach for lsof -i :4400 and kill what it lists; that matches every process on the port, including the proxy you are talking through.

Start here

  1. First move, always: reticle_session { action: "list" } finds the connected tab. Just ran reticle init or restarted the dev server? You do not need to poll: live tools wait briefly for a tab that is about to connect. Omit sessionId if there’s only one tab. An empty list is not a dead end: read why. It names the case (no app running, an app that never dialled this daemon, a project that is not wired, or a tab that closed) and the fix for each. In a project that is not wired, next_step also names the next calls. lease (present when an app is running) is the zero-install check of the running app: do it first, because it changes no file. consent is the question for init, which changes package.json, the lockfile and the build config: ask the user, and run init only if they agree. Do not fall back to static reasoning until you have read why.
  2. reticle_run({ tool: "reticle_domain", args: {} }) learns the app BEFORE testing: the saved flows, what each asserts, and the gaps (declared signals/testids that no flow verifies, i.e. untested intent). Tells you what to test and where the real risk is without crawling the whole app. Falls back to reticle_run({ tool: "reticle_capabilities", args: {} }) 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.

No MCP tools in this host

A cloud agent or a worker can have a shell and no MCP tools. Use the CLI. verify --expect asks a running daemon and never starts one, so start it first:
It exits 0 only on verified: "yes". --expect-file <path> reads the predicate from a file, and --timeout N sets how long it waits (default 4000 ms). To move a connected tab to another page first, run npx @reticlehq/server open <url> --navigate. For an action that --expect cannot do, use the HTTP MCP transport.

Token note

  • Keep observation cheap. Prefer reticle_look { action: "find" } / scoped or interactive reticle_look { action: "page" } / reticle_assert over dumping the full tree. A full verify loop is ~100 tokens; see Token efficiency (~73× leaner than full-tree snapshots).
  • Re-look with reticle_look({ action: "page", 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 }, so re-scope before reading if it’s large.
  • Cap broad reads. reticle_look { action: "find" } takes limit (caps descriptors; reports total/truncated) and count_only (just the match count). reticle_observe { action: "network" } / reticle_observe { action: "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_assert { action: "wait" } / reticle_act_and_wait is defined once (PredicateSchema in @reticlehq/core) and shared by all three tools. No action needed.
  • One tool surface, and a hatch behind it. Reticle advertises navigate/act/act_and_wait/assert/look/observe/session/verify, plus reticle_tools and reticle_run. That is the whole detect loop, the file-pointer, the feedback channel, the handback, and the tool that checks the whole app in one call, reached by an action argument rather than by a name each. reticle_tools discovers: no args lists every registered tool name and summary, names:[…] loads full params on demand. reticle_run { tool, args } then calls any of them, advertised or not, which is what keeps recording a flow, screenshots, visual diff, storage, network mocking and the clock reachable without spending a slot on each. A daemon started with RETICLE_ADVERTISE_ALL_TOOLS=1 (read at startup, so restart it) advertises a wider set outright (the default tools plus the extra ones listed above), with output schemas, under the unmerged names listed above. It is not the whole registry: the count is capped because editors budget tools across every connected MCP server. reticle_verify is advertised: { action:"flows" } replays every saved flow with no model in the loop, and { action:"crawl" } drives every reachable control itself and reports the whole fault set in one call. 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.
Last modified on November 9, 2026