Json-render devtools
Drop-in inspector panel for any json-render app. Use when the user wants to debug a generative UI, inspect the spec tree, edit state at runtime, see dispatched actions, follow stream patches live, browse a catalog, or pick DOM elements to find their spec keys. Triggers include "add devtools", "debug json-render", "inspect the spec", "why is this element not rendering", "see the state at runtime", or requests to tap streams / capture action logs for `@json-render/devtools`.
git clone https://github.com/vercel-labs/json-render
T=$(mktemp -d) && git clone --depth=1 https://github.com/vercel-labs/json-render "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/devtools" ~/.claude/skills/vercel-labs-json-render-devtools && rm -rf "$T"
skills/devtools/SKILL.md@json-render/devtools
A floating inspector panel for json-render apps. Framework-agnostic core + per-framework adapters (React, Vue, Svelte, Solid).
Production-safe: the component renders
null when NODE_ENV === "production".
Install
Install the core package plus the adapter that matches the host app's renderer.
# React npm install @json-render/devtools @json-render/devtools-react # Vue npm install @json-render/devtools @json-render/devtools-vue # Svelte npm install @json-render/devtools @json-render/devtools-svelte # Solid npm install @json-render/devtools @json-render/devtools-solid
Drop-in usage
Place
<JsonRenderDevtools /> anywhere inside the existing <JSONUIProvider> (or framework equivalent). No other wiring required.
React
import { JsonRenderDevtools } from "@json-render/devtools-react"; <JSONUIProvider registry={registry} handlers={handlers}> <Renderer spec={spec} registry={registry} /> <JsonRenderDevtools spec={spec} catalog={catalog} messages={messages} /> </JSONUIProvider>;
Vue
<script setup> import { JsonRenderDevtools } from "@json-render/devtools-vue"; </script> <template> <JSONUIProvider :registry="registry"> <Renderer :spec="spec" :registry="registry" /> <JsonRenderDevtools :spec="spec" :catalog="catalog" :messages="messages" /> </JSONUIProvider> </template>
Svelte
<script> import { JsonRenderDevtools } from "@json-render/devtools-svelte"; </script> <JSONUIProvider {registry}> <Renderer {spec} {registry} /> <JsonRenderDevtools {spec} {catalog} {messages} /> </JSONUIProvider>
Solid
import { JsonRenderDevtools } from "@json-render/devtools-solid"; <JSONUIProvider registry={registry}> <Renderer spec={spec()} registry={registry} /> <JsonRenderDevtools spec={spec()} catalog={catalog} messages={messages()} /> </JSONUIProvider>;
Controls
- Floating toggle appears bottom-right.
- Hotkey:
/Ctrl
+Cmd
+Shift
(configurable viaJ
prop).hotkey - Drawer is resizable; height persists to localStorage.
Props
(spec
) — current spec.Spec | null
(catalog
) — catalog definition; required for the Catalog panel.Catalog | null
(messages
) — AI SDKUIMessage[]
messages; scanned for spec data parts.useChat
(initialOpen
) — start open.boolean
(position
) — dock + toggle corner."bottom-right" | "bottom-left" | "right"
docks at the bottom;"bottom-*"
docks at the right edge full-height (recommended for app-shells that already use"right"
or fixed bottom bars).100vh
(hotkey
) —string | false
by default."mod+shift+j"
(bufferSize
) — event ring-buffer cap, default 500.number
(reserveSpace
, defaultboolean
) — when true the panel pushes the host app by applyingtrue
/padding-bottom
onpadding-right
. Set tobody
to keep the panel as a pure overlay.false
(allowDockToggle
, defaultboolean
) — show a toolbar button so the user can flip the panel between bottom-dock and right-dock. User choice persists totrue
and overrideslocalStorage
on subsequent mounts. Passposition
to lock the dock tofalse
.position
(onEvent
) — optional tap.(DevtoolsEvent) => void
Panels
- Spec — element tree rooted at
; props/visibility/events/watchers detail; integratedspec.root
warnings.validateSpec - State — every JSON Pointer path with inline edit via
.store.set - Actions — dispatched actions timeline (name, params, result/error, duration).
- Stream — spec patches, text chunks, token usage, lifecycle markers grouped by generation.
- Catalog — components + actions declared in the catalog with prop chips.
Picker (toolbar)
The element picker is a toolbar button in the panel header (Chrome-DevTools-style), not a tab. Click it to activate pick mode, then click any rendered element in the page — selection jumps to the Spec tab with that element focused.
Esc cancels.
Reserved space & docking
The panel can dock at the bottom or the right edge, and by default the user can flip between the two with a toolbar button (the choice persists to
localStorage). Set allowDockToggle={false} if the host app only works with one dock — the button is hidden and the dock is locked to position.
Pick an initial dock that fits your layout:
- Bottom dock (default) — works best for docs / marketing / content-flow sites and for app shells built with a
chain (height: 100%
→html { height: 100% }
→body { height: 100% }
). The panel writes its height to.app { height: 100% }
and applies matching--jr-devtools-offset-bottom
topadding-bottom
, so non-fixed content naturally makes room.body - Right dock (
) — recommended for app-shell layouts that useposition="right"
or100vh
. Right docking sidesteps the bottom edge entirely and writes its width toposition: fixed; bottom: 0
instead.--jr-devtools-offset-right
Apps that use
100vh, position: fixed, or position: sticky can opt specific elements in with the published CSS custom properties:
.composer { bottom: var(--jr-devtools-offset-bottom, 0); } .sidebar { right: var(--jr-devtools-offset-right, 0); } .app-shell { height: calc(100vh - var(--jr-devtools-offset-bottom, 0)); }
If the automatic body padding causes problems with a particular layout, pass
reserveSpace={false} to make the panel a pure overlay — the CSS custom properties are still published so you can reserve space manually.
(
--jr-devtools-offset is kept as a back-compat alias for whichever edge is currently active.)
Multiple renderers on one page (e.g. a chat)
A single
<JsonRenderDevtools /> can inspect many <Renderer /> instances at once — a chat where each assistant message renders its own spec, a dashboard made of several independent widgets, etc. The recipe:
- One top-level
so every renderer shares one state store and one action dispatcher. Devtools lives inside this provider and sees everything through it.<JSONUIProvider> - Per-renderer specs, shared state — each assistant message renders
directly, not wrapped in its own<Renderer spec={msgSpec} registry={registry} />
. State paths from different messages must not collide.StateProvider - Namespace state per turn — when the source is an AI stream, hand the agent a unique
and require every element key (messageId
) and state path (<id>-root
) to be prefixed with it./<id>/count - Pass
+spec={latest}
—messages={all}
drives the Spec panel (usually the newest assistant message's spec), whilespec
feeds the Stream panel with patches from every turn.messages - Actions and the picker are already global —
captures dispatches from anyregisterActionObserver
in the tree, andActionProvider
is written by the renderer itself, so Pick works across every rendered element regardless of which message produced it.data-jr-key
See
examples/devtools for a full AI chat wired this way.
Imperative API (React only)
import { useJsonRenderDevtools } from "@json-render/devtools-react"; const devtools = useJsonRenderDevtools(); devtools?.open(); devtools?.toggle(); devtools?.recordEvent({ kind: "stream-text", at: Date.now(), text: "hi" });
Returns
null in production or before the component mounts.
Server-side stream tap
Capture spec patches at the API route so events persist server-side or flow into your own telemetry.
import { tapJsonRenderStream, createEventStore } from "@json-render/devtools"; import { pipeJsonRender } from "@json-render/core"; const events = createEventStore({ bufferSize: 1000 }); const tapped = tapJsonRenderStream(result.toUIMessageStream(), events); writer.merge(pipeJsonRender(tapped));
YAML equivalent:
tapYamlStream.
Under the hood
- Shadow-DOM isolated panel — the panel's styles never leak into the host app and vice versa.
- Ring-buffered event store — capped log of devtools events (state changes, action dispatches, stream patches, etc.).
- Action observer registry — each framework's
reports viaActionProvider
/notifyActionDispatch
innotifyActionSettle
; devtools subscribes via@json-render/core
.registerActionObserver - Picker element tagging — while devtools is mounted,
wraps each rendered element inElementRenderer
so the picker can map DOM → spec key. No layout impact.<span data-jr-key="..." style="display:contents">