Reticle has three moving parts: a development SDK inside your page that instruments the DOM, network, console, routing and framework state; a local server that runs a WebSocket bridge to that SDK and an MCP server for your agent; and a git-checked .reticle/ workspace holding flows, baselines and run artifacts. Your agent calls an MCP tool, the server turns it into a command over the WebSocket, the SDK runs it in the real page and streams structured events back.
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; 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_look { action: "find" }, 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.
The packages (and the boundaries between them)
Reticle is a pnpm + Turborepo monorepo. The split is not cosmetic: each boundary enforces a rule.
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 never flows into logic. A malformed HELLO is refused, and a malformed frame on a live session is dropped and recorded as a gap in that window.
The data flow, end to end
- 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. A second connect() on a live page does not open a second socket. If it names a projectId and the first call did not, the SDK sends HELLO again with the id, and the bridge moves the session to that project. An app that calls connect() itself, before the build plugin’s connect({ projectId }), keeps its project this way.
- 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, so recent history is always available and memory is capped.
- Look / act. The agent calls an MCP tool.
reticle_look { action: "find" } 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.
- Observe. After an action, the agent reads what happened with
reticle_observe { action: "network" }, reticle_observe { action: "console" }, reticle_look { action: "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.
- 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 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. Development by default, your app data stays local
By default the SDK is tree-shaken out of production builds and connects only to a local bridge. Production mode is the one exception: it is opt-in, and a page loads the SDK only with a signed, single-use activation ticket. The bridge binds to loopback by default. It refuses a connect without the pairing token, also on localhost. Exposing it beyond localhost also needs the token (and the server refuses to start on a non-loopback host unless RETICLE_ALLOWED_ORIGINS names the app’s origins). 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 leaves your machine unless you link a project: no DOM, network, console, state, or source. What a linked project syncs is set by reticle config; see what is recorded. The CLI reports anonymous, opt-out usage metrics only (a random id + event names like cli_command_run/daemon_started; no code, no PII; see telemetry); 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_session { action: "feedback" }). It is never collected passively, it is redacted before sending, and it is disabled separately with RETICLE_FEEDBACK=0.
State on disk: the .reticle/ workspace
The server persists project state under .reticle/ in your repo:
flows/ holds recorded, replayable flows (the golden journeys).
baselines/ holds saved snapshots for diffing.
runs/ holds 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 is 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 → Running multiple apps at once.
- CI / no MCP:
reticle verify <url> replays the saved flows headlessly and exits non-zero on failure. It writes the same verdict artifact the MCP path produces, with no agent in the loop.
Licensing
Every npm package is Apache-2.0: the protocol and the rules (open-verification, @reticlehq/core, @reticlehq/engine), the embeddable SDK and its adapters, and the server, CLI, init and spec runner. The reticle-tauri crate is MIT.
See LICENSE and each package’s own LICENSE file.
Where to go next
- Getting started wires Reticle into your app in a couple of minutes.
- Benchmarks covers how we measure, and the honest results against the alternatives.
- Usage is the full tool reference and advanced modes.
- CONTRIBUTING.md has the development loop and the rules.
Last modified on October 29, 2026