Skip to main content
Run npx @reticlehq/server doctor first. It checks Chromium, the daemon, the port and the connected sessions in one command, and it names the problem in most cases. If sessions reads zero, your app is not reaching the bridge, and a port mismatch is the usual reason. Reticle is a verification layer that runs a dev-only SDK inside your running web app. Two halves have to be working: your agent needs the MCP server registered, and your app needs the SDK connected to the local daemon. Almost every failure below is one of those two halves being absent. Most of these were hit while writing these docs. The diagnostics quoted are real output.

Start here

One command, the whole setup. If this is clean and things still fail, the problem is in the app rather than the wiring. The sessions line is the one to read first. Zero connected pages is a failing check, not a footnote, because it is the state a setup most often stalls in: everything installed, the daemon up, and nothing having dialled in. When it is zero, doctor prints the same diagnosis described below underneath the checklist, so you do not have to call a tool to see it.

”No browser session connected”

By far the most common. Reticle’s own diagnostic is unusually good here. It tells you whether the wiring was ever correct:
Read the why. If it says one was connected earlier, stop debugging your config and just reopen the tab. If it says nothing ever connected, the wiring itself is wrong, so work through the list below. If nothing ever connected:
  • Is the app running, and open in a browser? If nothing is serving it, see Who starts the dev server: this is the agent’s job, not a question to put to you.
  • Is reticle.connect() actually executing? Check the dev guard is true.
  • Port mismatch. This is the big one.

Who starts the dev server

The agent does. If no dev server is listening, the agent reads your project’s own dev script out of package.json, runs it in the background, tells you in one line that it is running and how to stop it, and carries on. It used to be told the opposite, “Reticle never starts the dev server for you, that’s your job”, and agents obeyed it exactly: they read the rule, declined to start anything, and ended the turn with nothing verified. The daemon still does not do it, on purpose. A build process started by a long-lived background daemon is invisible to whoever’s machine it is running on, was never consented to, cannot easily be stopped, and orphans when the daemon exits. It would also mean a piece of standing infrastructure runs arbitrary package.json scripts. An agent already has shell access in your repo, already runs install and build commands, and runs inside a host that asks you before it does. A dev server the agent starts is in the transcript, attributable to a step you approved, and stoppable. The agent does not have to work out the command. When reticle_session { action: "list" } comes back with nothing connected, it carries a next_action. Its action names the case: daemon_split (the app is on a different daemon; start nothing), start_dev_server, run_init, open_app or reopen_app. Where one applies, it also gives the literal command and the port. In a project that was never wired, it also carries next_step. Its lease checks the running app now with nothing installed. Its consent is the question to ask before reticle init changes files. Run init only when the user agrees. The command is read from this project’s own scripts, so a project with no recognisable dev script gets no command rather than a guess. The guards, which apply wherever this instruction appears:
  1. Never start a second one. If something is already listening on the app’s port, it is adopted.
  2. Never guess the command. It comes from package.json scripts. If there is no recognisable dev script, the agent says so and stops rather than inventing one.
  3. Never kill anything: not a dev server, not a daemon, not a port holder, including one it started.
  4. Background it, and say so. You must know a server is running and how to stop it. A dev server you do not know about is the same failure one step later.
  5. The permission prompt is your agent host’s. Nothing in Reticle bypasses or auto-approves it, and you can always decline.
The corollary matters as much: if a dev server IS already running and nothing connects, the cause is the SDK not loading in the page. An agent that answers this state by asking you to start a server you are already running has ended the conversation with nothing fixed. Work the checklist above instead.

The dev server runs in a container

Everything below the CLI assumes the daemon and the dev server share a filesystem. Three things break when they do not (Docker, a devcontainer, WSL, or a dev server on another machine), and all three look identical from the page: the SDK loads, the socket opens, and no session appears. 1. The pairing token. The daemon writes ~/.reticle/pairing-token on the host. The build plugin reads it from $HOME inside the container, finds nothing, mints its own, and inlines that into every page, which the bridge then refuses. The console says bridge refused the connection: authentication failed, and reticle status names this case in full. Mount the host’s token read-only and point the plugin at it:
Run the daemon at least once first, or Docker creates a directory at that host path. The token is read when the dev server resolves its config and inlined then, so restart the dev server afterwards. 2. The projectId. The plugin reads it from .reticle.json, the file reticle init writes. If that file is not visible inside the container (it is not in the build context, or the source is not mounted), the plugin falls back to deriving one from the absolute project root. That is /app inside and your host path outside, so the two disagree. Make sure .reticle.json is mounted with the source, or pass the id explicitly: reticle({ projectId: 'your-id-from-.reticle.json' }). 3. The dependencies. A container that installs its own node_modules into an anonymous volume will not see the two packages init added by restarting. Rebuild the image and renew the volume:
reticle init prints all three as a notice when it finds a Dockerfile or a compose file near the app, so on a fresh install you should meet this page having already been told.

The port mismatch

Your app dials a port; the daemon listens on one. When they disagree, the app runs perfectly and silently never connects. The browser console says so:
You should not have to read this page to find that out. If the agent opened the app with reticle_lease, which is the path init steers it to, the daemon reads that address off the page’s own console, compares it with the port it is bound to, and puts the mismatch in the tool result:
Neither side can work that out alone: the page cannot see the daemon, and the daemon never saw a dial that went somewhere else. The rest of this section covers what the lease cannot: a tab you opened yourself, or an app the pool cannot reach. That message names the exact address it tried. Compare its port with bridge port from doctor and set whichever is wrong:
That works with the Vite plugin, which reads RETICLE_PORT when the dev server starts. You can also set reticle({ port: 4400 }) in vite.config, or pass reticle.connect({ url: 'ws://localhost:4400/reticle' }) if you wired connect() by hand.
The bridge port is not your dev-server port. A dev server on 4312 and a daemon on 4400 is normal and correct.

The click did nothing

Check whether the tab is throttled. Every response tells you:
A backgrounded browser tab has its timers suppressed. Synthetic pointer gestures and timers can silently no-op, so an action reports success and nothing happens. Three fixes, in order of preference:
  1. Drive a lease. A context Reticle owns, never backgrounded:
    Release it when you finish: { "action": "release", "sessionId": "lease-…" }.
  2. Focus the tab.
  3. Fail loudly instead of pretending. Pass refuseWhenThrottled: true to your action.
If the tab is fine, check occluded on the element. A click that lands on a modal overlay is the other half of “the button doesn’t work”:

“ref no longer resolves to an element”

This is Reticle protecting you, not failing. Re-query for a fresh ref. Better: use reticle_act_and_wait { until } for actions that change the page, so the next ref is taken after things settle.
Refs never survive a full navigation. A new document means new refs, the same button was e5 before a navigation and e103 after.

The verdict says unknown

verified: "unknown" means Reticle drove the app and could not tell. It is not a pass. The usual cause is a throttled tab: throttling suppresses the quiescence detection used to decide the page has settled, so even a successful action can come back unsettled. Drive a lease and try again. If it persists on an unthrottled context, that is worth reporting. Reticle’s own response says so:

A negative result you are not sure about

Before concluding “no request fired”, read the buffer note:
“Nothing found” and “I no longer have what you asked about” are different answers. Grade sooner, or widen the buffer.

The agent cannot find an element

  • reticle_look { action: "page", mode: "interactive" } shows everything actionable.
  • Add a data-testid for a stable handle.
  • Narrow with scope. A CSS selector or a ref.

Assertions are flaky on async UIs

  • Use timeout_ms on assert and wait_for.
  • By default an assertion counts only the events after your last act. Pass a since cursor from reticle_act or reticle_observe to set that window yourself.

reticle_look { action: "state" } returns nothing

storeNames empty means no store was registered. reticle_look { action: "state" } reads what your app registered in src/reticle-dev.ts.
Pass the store, not () => store.getState(). The getter form is read-only and silently produces empty stateDiffs. Which reads as “nothing changed” and means “I was never watching”.

Source file is not resolving on React 19

React 19 dropped _debugSource. Wire up @reticlehq/babel-plugin. Or just use @reticlehq/vite-plugin or @reticlehq/next, which both include it. Without it you get component identity but no file:line.

A lease on an https dev server with a local certificate

A dev server on https:// with a self-signed or mkcert certificate makes reticle_lease { "action": "acquire" } fail with net::ERR_CERT_AUTHORITY_INVALID: the browser does not trust a certificate that no CA vouches for. The result says so and names the fix. If the server is yours, acquire again with ignoreHTTPSErrors set:
It is off by default. When it is on, certificate checks are skipped for every site opened in that lease’s browser context, including resources loaded from other sites. Other leases keep their checks. A context cannot change the setting once it is made, so acquiring again with the other value replaces the lease instead of reusing it.

The page never connects under a strict CSP

A build that sends a strict Content-Security-Policy blocks the bridge socket. A production build that you run locally (next start, vite preview) often sends one. The app runs, and the SDK never connects, with no error in the app. Add the bridge to connect-src, with your bridge port if it is not 4400:
Add it only in the builds that Reticle drives, never in the policy that you deploy. If the policy also blocks inline scripts, script-src stops a pasted connect snippet before it runs: serve that snippet as an external module. reticle doctor reads the policy that the page serves and names the line to add, and a lease result says the same. For production mode, add the wss:// bridge from the ticket instead. Desktop apps have their own note in Desktop.

ERESOLVE installing next to a prerelease

@reticlehq/next declares peer next >=13 and @reticlehq/react declares peer react >=18. On a Next canary or React RC, npm refuses. See the prerelease notes.

The tool list is empty

Some clients read their MCP tool list once, at startup, and never again. Cursor Cloud Agent is the usual report: the daemon is listening on port 4400, the app is instrumented, and reticle_* is absent for the whole session. Restarting the client picks the tools up when a restart is possible. When it is not, the same tools are on the HTTP transport. GET /mcp/sse opens the session. The first SSE frame is an endpoint event whose data is the path to POST to, including the session id. POST JSON-RPC there. The POST answers 202 with an empty body, and the result comes back on the SSE stream. The handshake and a working client are in HTTP transport.

Restarting things

Do not kill port 4400 with lsof -ti tcp:4400 | xargs kill -9. That matches the MCP proxy as well as the daemon and takes your agent’s connection down with it. Use reticle stop, or at minimum lsof -sTCP:LISTEN.

Sessions piling up

Stale sessions pointing at dev servers you restarted an hour ago look completely plausible in a list, and one of them will get picked.
reticle_session { action: "end" } when the whole task is done, { action: "yield" } when pausing. A yielded session revives on your next action; an ended one stays ended.

Frequently asked questions

Your app running is not the same as your app being connected. The SDK has to dial the daemon’s bridge port over a WebSocket. If npx @reticlehq/server doctor shows the daemon up and sessions at zero, either reticle.connect() never executed (check the dev guard is actually true) or the app dialled the wrong port. The browser console names the exact address it tried.
The bridge port, which defaults to 4400. It is not your dev-server port. A dev server on 4312 and a daemon on 4400 is the normal, correct arrangement. doctor prints the bridge port under bridge port.
If you registered the MCP server while the agent was running, restart the agent process itself: quit and reopen Claude Code, reload the window in Cursor, or press Start in .vscode/mcp.json in VS Code. /mcp only manages servers that were already loaded, so it cannot discover a new one. If the tools are still missing, run claude mcp list and check reticle is present.
No, and it does not mean it passed either. verified: "unknown" means Reticle drove the app and could not tell what happened, usually because the page never settled inside the observation window. Report it as unknown. If you hit one on an unthrottled context and believe it is wrong, that is a Reticle defect worth sending with reticle_session { action: "feedback" }.
No. lsof -ti tcp:4400 | xargs kill -9 matches your agent’s MCP proxy as well as the daemon and takes the connection down with it. Use npx @reticlehq/server stop, or at minimum add -sTCP:LISTEN so only the listener matches.
Check the response for a warning about a throttled tab. A backgrounded browser tab has its timers suppressed, so synthetic pointer gestures can silently no-op while the action still reports success. Drive a lease instead, which is a context Reticle owns and never backgrounds. If the tab is fine, check occluded on the element: a click landing on a modal overlay looks identical from the outside.

Still stuck?

reticle feedback --agent works even when nothing is installed and the daemon will not start.
Last modified on November 4, 2026