| title | Frameworks: proven, wired, and the difference |
|---|---|
| sidebarTitle | Frameworks |
| description | Which frameworks Reticle supports, which are wired but unverified, and the exact wiring for each one. React, Next.js, Remix, Astro, SvelteKit, TanStack Start, Nuxt, Vue, Angular and plain HTML. |
| icon | layer-group |
React + Vite, Next.js, Remix and Astro are fully supported, each with an example app in CI: React + Vite and Next.js are driven to a verdict by the e2e battery, and the Remix and Astro examples are booted and must register a session. SvelteKit, Nuxt, React Router framework mode, Create React App, Vue and Angular have their INSTALL gated: the install gate scaffolds each from scratch whenever the install path changes and requires a real session, but no gate drives them to a verdict. TanStack Start, Preact and plain HTML are wired by init and documented but not gated at all, so treat them as plausible rather than proven. The exact wiring for each is below.
Reticle is a verification layer that embeds a dev-only SDK in your running web app so an AI coding agent can prove a change works. Its core is framework-neutral: DOM, network, console, routing, animations and source mapping work anywhere JavaScript runs.
What varies is two things: how connect() gets into your app, and whether you get component identity on top.
Source pointer is the file:line on a verdict, the thing an agent follows straight to the code after a red. It comes from the build plugin stamping your JSX, not from the SDK, so it is a property of how the framework is wired rather than of the framework. Remix and Astro render their own HTML, so the plugin's connect-injection cannot fire there, and both examples used to drop the plugin entirely, which quietly cost them the stamping too. Both now wire it with inject: false, which keeps the half that applies. A row marked unproven has no example app, so nobody has checked.
We separate "we tested this" from "this should work". Four frameworks have an example app and a CI gate. The rest are wired and documented but nothing proves they still work.
Two columns say different things and it is worth keeping them apart. CI gate yes means an example app runs in CI: React + Vite and Next.js are driven to a verdict, Remix and Astro are booted and must register a session. install only means the install gate scaffolds that stack from scratch, runs init, boots it and waits for a session, so the setup is proven and the drive is not. Vue is there because a release once nearly shipped an installer that left a Vue app connecting 0% of the time, and the gate that exists for exactly that had only React scaffolds.
Source pointer is the file:line on a verdict. It needs the build plugin to stamp the source, which it does for JSX and, separately, for Svelte components. Vue single-file components are neither, so a Vue app gets every other signal and no source field. Measured, not assumed: a Svelte counter returns src/lib/Counter.svelte:5 and the same drive on Vue returns none.
| Framework | init detects |
Example app | CI gate | Source pointer | Status |
|---|---|---|---|---|---|
| React + Vite | yes | yes | yes | yes | Supported |
| Next.js | yes | yes | yes | yes | Supported |
| Remix | yes | yes | yes | yes | Supported |
| Astro | yes | yes | yes | yes | Supported |
| SvelteKit | yes | no | install only | unproven | Install gated, drive unverified |
| TanStack Start | yes | no | no | unproven | Wired, unverified |
| Nuxt | yes | no | install only | no | Install gated, drive unverified |
| React Router (framework mode) | yes | no | install only | yes | Install gated, drive unverified |
| Create React App | yes | no | install only | unproven | Install gated, drive unverified |
| Angular 17+ | yes | no | install only | no | Install gated, drive unverified |
| Plain HTML | yes | no | no | no | Wired, unverified |
| Vue (non-Nuxt) | as Vite | no | install only | no | Install gated, drive unverified |
| Preact | as Vite | no | no | transform-tested; drive unproven | Sensor, React adapter via preact/compat |
| Server-rendered (Django, Flask, FastAPI, Streamlit, Rails) | yes | no | no | no | Wired, unverified |
init detects the UI library separately from the framework. That distinction exists because detection used to stop at "vite is in package.json", which installed the React adapter into Vue and Preact apps and reported all green with nothing attached.
The shortest path, and the one with the most in it.
npm i -D @reticlehq/react @reticlehq/vite-pluginimport { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { reticle } from '@reticlehq/vite-plugin';
export default defineConfig({
plugins: [reticle(), react()],
});That is the whole install. The plugin injects connect() and stamps source locations, which is what turns a DOM node into src/components/Login.tsx:81.
Keeps SWC. No Babel migration.
npm i -D @reticlehq/react @reticlehq/nextconst { withReticle } = require('@reticlehq/next');
module.exports = withReticle({/* your config */});Remix v2 on Vite is its own framework to init, not plain Vite. Remix SSRs its own HTML, so the plugin's index.html injection never reaches the page and the connect comes from app/entry.client.tsx instead, the same way as React Router framework mode. init adds the dev-only connect line to that entry, or writes the file when the app has none: app/entry.client.tsx only exists after remix reveal, so the written file is Remix's own default entry (RemixBrowser, hydrateRoot) with the one line added. The Vite plugin, reticle() in the vite.config that carries vitePlugin, stamps file:line and serves the connect module the entry imports. The classic Remix compiler (no vite.config) gets a manual step rather than a green plan, because there is no plugin to serve that module.
Astro uses the Vite plugin (wire it with inject: false if you want source pointers on JSX islands) and uses a different channel for the pairing token. vite.define does not reach Astro's client pipeline on 7.2+, so the identifier stays literal and the bridge refuses the dial. init reads the token in frontmatter and puts it on a <meta name="reticle-pairing-token">. Reading it per request also means a token written after the dev server started is picked up on the next load, with no restart.
Use a processed page <script> with a static SDK import. Vite owns that module graph from the first transform:
---
import { readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
const pairingToken = (() => {
if (!import.meta.env.DEV) return '';
const dir = process.env['RETICLE_PAIRING_TOKEN_DIR'] || join(homedir(), '.reticle');
try { return readFileSync(join(dir, 'pairing-token'), 'utf8').trim(); } catch { return ''; }
})();
const pairingRoot = import.meta.env.DEV ? process.cwd() : '';
---
<meta name="reticle-pairing-token" content={pairingToken} />
<meta name="reticle-pairing-root" content={pairingRoot} />
<script>
import { reticle, install } from '@reticlehq/react';
if (import.meta.env.DEV) {
const token = document.querySelector('meta[name="reticle-pairing-token"]')?.getAttribute('content') ?? '';
const root = document.querySelector('meta[name="reticle-pairing-root"]')?.getAttribute('content') ?? '';
install();
reticle.connect({
...(0 < token.length ? { token } : {}),
...(0 < root.length ? { root } : {}),
});
}
</script>The <script> tag must not sit inside a conditional. Astro hoists them statically, so {isDev && (<script />)} is never hoisted. init writes the same <meta> tags and a local src/components/ReticleDev.ts that the script statically imports. It also raises vite.build.target to es2022 (Astro's default down-levels the SDK), declares the SDK in optimizeDeps.include, and keeps .reticle/ out of the watcher. Static .astro markup still gets DOM, network and routing without the React integration.
SvelteKit renders through app.html and never triggers Vite's index.html injection, so the plugin cannot auto-connect. A client hook is the reliable path, and it is what init writes.
// src/hooks.client.ts (abridged)
if (import.meta.env.DEV) {
void import('@reticlehq/browser').then(({ reticle }) => {
// Inlined by @reticlehq/vite-plugin. The bridge refuses a connect with no token, even on localhost.
const token = typeof __RETICLE_TOKEN__ !== 'undefined' ? __RETICLE_TOKEN__ : '';
reticle.connect({ ...(token.length > 0 ? { token } : {}) });
});
}The Vite plugin still stamps data-reticle-source into .svelte components, so verdicts carry file:line.
For state, use the svelteStore adapter. See State management.
TanStack Start SSRs <html> from src/routes/__root.tsx and never sends Vite's index.html, so the plugin cannot auto-connect. Keep the plugin with inject: false: only the injection half is inapplicable, and the stamping half is what puts file:line on a verdict.
Connect from a client-only effect. A static import of the SDK on __root.tsx SSRs and 500s.
useEffect(() => {
if (import.meta.env.DEV) {
const token = typeof __RETICLE_TOKEN__ !== 'undefined' ? __RETICLE_TOKEN__ : '';
void import('@reticlehq/react').then(({ reticle, install }) => {
install();
reticle.connect({ ...(token.length > 0 ? { token } : {}) });
});
}
}, []);init writes this for you: a src/reticle-connect.tsx client effect that connects, declares __RETICLE_TOKEN__ so tsc passes, and loads src/reticle-dev.ts, plus <ReticleConnect /> rendered before <Scripts /> in src/routes/__root.tsx. When the root route is not a shape it can patch, it prints a recipe naming the real parts (shellComponent, <Scripts />) instead. There is no example app and no CI gate, so treat a connected session as the proof, not the plan.
Nuxt owns its own Vite instance and renders its own HTML. There is no vite.config to patch and no index.html to inject into, so reticle init writes a dev-only client plugin and adds a vite.define block to nuxt.config. That block is the only place a Nuxt app can inline the pairing token, which the bridge requires even on localhost. What it writes is this, and the notes below are why each line is the way it is.
// app/plugins/reticle.client.ts (Nuxt 3: plugins/reticle.client.ts), abridged
export default defineNuxtPlugin(() => {
if (!import.meta.dev) return;
void import('@reticlehq/browser').then(({ reticle }) => {
// Both inlined by the vite.define block init adds to nuxt.config.
const token = typeof __RETICLE_TOKEN__ !== 'undefined' ? __RETICLE_TOKEN__ : '';
const root = typeof __RETICLE_ROOT__ !== 'undefined' ? __RETICLE_ROOT__ : '';
reticle.connect({
...(token.length > 0 ? { token } : {}),
...(root.length > 0 ? { root } : {}),
});
});
});Three traps, all reported from the field rather than imagined:
It is what keeps the plugin out of the server bundle. Without it you are running the SDK during SSR, where `window` does not exist. `window.location.hostname === 'localhost'` fails twice over here. `window` does not exist in SSR, and the check is false on any hosts-file alias or LAN address. `import.meta.dev` resolves at build time and does not care what host you develop on. A running dev server does not pick up a new plugin. It will not appear in `.nuxt/plugins/client.mjs`, and the app comes up with no SDK at all and no error saying so.Use @reticlehq/browser, the framework-neutral sensor. You get DOM, network, console and routing. You do not get React component identity (the React adapter adds that) or a source field: Vue single-file components are not stamped.
Vue apps outside Nuxt are Vite apps, so init finds them. It installs the sensor rather than the React kit, plus the Vite plugin:
npm i -D @reticlehq/browser @reticlehq/vite-pluginAdd reticle() to the plugins of your Vite config, as in the React example above. The plugin injects connect() with the pairing token, so there is no connect line to write.
Pinia is a first-class store. See State management.
Detected as its own UI library, because a Preact app using preact/compat aliases React and would otherwise be handed the React kit with nothing to attach to.
The sensor works. If you use preact/compat, the React adapter can attach through the alias, but that path is not gated.
The source-pointer transform works on Preact code, and that is measured rather than assumed. The stamper is JSX-syntactic: it tags a host element by its tag name and reads nothing about React, so a Preact .jsx or .tsx file is stamped exactly as a React one is. class instead of className, a @jsxImportSource preact pragma and a @preact/signals value in the tree all leave it unmoved. There are tests for each of those in adapters/build/babel-plugin. They prove the Babel transform, not the @preact/preset-vite pipeline end to end: nobody has yet driven a Preact app and seen a verdict name a file:line, so the drive stays unproven until someone does.
What is still missing on Preact is the enrichment above the DOM: reticle_inspect returns no component/componentStack, @preact/signals has no reticle_state adapter, and the render meter counts React commits only. Tracked in #129.
No config file to patch, so connect() goes in your entry module.
// src/index.tsx
import { reticle } from '@reticlehq/browser';
if (process.env.NODE_ENV !== 'production') {
// The bridge refuses a connect with no pairing token, even on localhost.
reticle.connect({ token: '<pairing token>' });
}The token is the contents of ~/.reticle/pairing-token; do not commit it. On Create React App, init writes this for you: a dev-only connect module that reads the token from REACT_APP_RETICLE_TOKEN, which it puts in the gitignored .env.development.local. The same shape works for webpack, Parcel, and the Vue and Svelte CLIs.
A directory with an index.html and no package.json is wired by init itself: it writes the connect snippet into index.html, before </body>, writes .reticle.json, and hands over to setup. The block is marked, so a re-run finds it and never writes a second one, and a re-run with a new --port rewrites the port inside it. It loads the SDK from a CDN and runs only when the page is opened on localhost, 127.0.0.1 or [::1]: a static file has no development build to strip it, so the host is the only line between the snippet and a published page. The page carries no pairing token: init writes it into reticle.local.js beside the page, which the snippet imports, and adds that file to .gitignore. Keep it out of anything you publish, and let each teammate run reticle init for their own. Several pages and no index.html is a choice init does not make for you: it prints the snippet, filled in with your project id and token, for you to paste:
<script type="module">
import { reticle } from 'https://cdn.jsdelivr.net/npm/@reticlehq/browser@<version>/+esm';
reticle.connect({ projectId: '<your-project-id>', token: '<pairing token>' });
</script>A bare /node_modules/... import does not work here: the SDK's own dist imports bare specifiers, which a browser cannot resolve without a bundler. A page with a package.json and a bundler takes the entry-module path above instead.
Angular 17 and later, on the Vite-based @angular/build dev server. init installs only @reticlehq/browser, the framework-neutral sensor, and makes three edits:
- The browser entry (
src/main.ts, or whateverangular.jsonnames) gets a connect guarded on Angular'sngDevModeglobal (typeof ngDevMode === 'undefined' || ngDevMode), through a dynamicimport('@reticlehq/browser')appended after the bootstrap. A productionng builddefinesngDevModeasfalse, so the build drops the block and the chunk with it. The output carries no Reticle code at all, and the app never waits on Reticle.isDevMode()is not used because the build cannot fold a function call; an entry wired that way by an olderinitis rewritten in place on the next run. reticle.proxy.mjs, besideangular.json, answers one same-origin route,/__reticle/pairing-token, with this machine's pairing token, read from~/.reticle/pairing-tokenat request time. It refuses a request that did not arrive over loopback or came from another origin.angular.jsongets the serve target'sproxyConfigpointed at that file.ng serveloads it;ng buildnever reads a proxy config, so a production bundle has no route to the token and the token is never written into the project.
The token takes that route because the Angular CLI has no config file that runs code: a literal in angular.json commits a per-machine secret, and a gitignored module imported from main.ts is bundled into ng build whether or not anything can reach it. An existing proxyConfig is yours and is never replaced: the step goes manual and names the entry to add to it. A workspace with several served applications is also manual, because which one is the app is not init's to guess.
You get DOM, network, console, routing and state tools. You do not get component identity (that is the React adapter) or a source field (the stamper covers JSX and Svelte). The install gate scaffolds an Angular app (no SSR) whenever the install path changes and requires a session; SSR was proven by hand, and nothing drives an Angular app to a verdict.
There is no JS build to import the SDK from, and no single template to paste a script tag into: an app has many templates and may not have a base one. The SDK loads from a URL instead, so a page with no build step is not a blocker.
reticle init detects these and prints the right thing for the stack it found, writing .reticle.json as it goes. What follows is the same wiring, written down so you can read it before running anything, or wire it by hand.
Middleware is the Django-shaped answer: one place, runs for every page, no template to find.
# reticle_dev.py: add to MIDDLEWARE while DEBUG is on:
# MIDDLEWARE = [..., "reticle_dev.ReticleDevMiddleware"]
from django.conf import settings
_SNIPPET = """<script type="module">
import { reticle } from 'https://cdn.jsdelivr.net/npm/@reticlehq/browser@<version>/+esm';
reticle.connect({ projectId: '<your-project-id>', token: '<pairing token>' });
</script>"""
class ReticleDevMiddleware:
"""Inject the Reticle SDK into HTML responses during development only."""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
if not settings.DEBUG:
return response
if "text/html" not in response.get("Content-Type", ""):
return response
# Streaming responses have no .content to rewrite; leave them alone.
if getattr(response, "streaming", False):
return response
body = response.content.decode(response.charset)
if "</body>" not in body:
return response
response.content = body.replace("</body>", _SNIPPET + "</body>", 1).encode(
response.charset
)
if response.has_header("Content-Length"):
response["Content-Length"] = str(len(response.content))
return responsereticle init emits this with your real version, project id and pairing token filled in. Copy it from there rather than from this page: the placeholders above are the three things that have to be right.
Four details in that middleware are load-bearing, and each is there because the obvious version is wrong:
settings.DEBUGis the guard. Reticle is dev-only, and this is the lock that cannot be forgotten at deploy time.- the
text/htmlcheck keeps it from corrupting a JSON API response or a file download. - the
streamingcheck exists because aStreamingHttpResponsehas no.contentto rewrite, and touching it raises. </body>, not<head>so the SDK sees a parsed document.
Re-setting Content-Length matters for the same reason: the body grew, and a stale length truncates the page in the browser rather than failing loudly.
Same shape, one hook instead of a middleware class: an @app.after_request in Flask, or a middleware in FastAPI, with the same four guards: a debug flag, text/html only, not streaming, and injected before </body>.
Different again: Streamlit owns the top-level document, so there is no served template, and scripts passed to st.markdown do not run. Use st.html on Streamlit 1.63 or newer. reticle init prints the helper, which also guards against connecting twice across reruns.
Everything that comes from reading the live page works: queries, snapshots, actions, network and console channels, verdicts.
What is missing is the source pointer. file:line on a verdict comes from the build plugin stamping data-reticle-source, and there is no build step here to stamp it. You get every other signal and no source field, the same way a Vue single-file component does.
Electron and Tauri invert the direction: your app dials the daemon, and there is no URL for an agent to open. Reticle also reaches the main-process and Rust IPC boundary, which the renderer cannot see.
Electron and Tauri, IPC observation, and the CSP trap whose failure is completely silent.Solid appears in dependency detection but has no wiring, no adapter and no gate. The sensor may work if you call connect() yourself. Nothing proves it, so we are not going to list them as supported.