Debugging LLui Apps
LLui ships a unified debug API that gives you the same view of a running app from three places:
- The browser console —
window.__lluiDebugexposes the full surface. - An MCP-connected LLM —
@llui/mcpwraps the same API as Model Context Protocol tools, so Claude Code or Claude Desktop can drive the debugger interactively. - Idiomatic-code feedback —
llui_lintchecks generated source against the compiler's signal lint rules without needing a build.
This page is for developers writing LLui code. If you're an end user who wants to drive an LLui app via Claude, see the Agents guide instead.
In-page console: window.__lluiDebug
When the Vite plugin is active in dev, the runtime publishes the debug
API on window.__lluiDebug. Open DevTools and start poking:
__lluiDebug.getState()
__lluiDebug.send({ type: 'inc' })
__lluiDebug.evalUpdate({ type: 'inc' }) // dry-run → { state, effects }
__lluiDebug.searchState('cart.total')
const snap = __lluiDebug.snapshotState() // hold the returned clone
__lluiDebug.restoreState(snap)
__lluiDebug.exportTrace()
The console surface is exactly what the runtime's installSignalDebug
registers: getState, send, evalUpdate, getMessageHistory,
searchState, validateMessage, getMessageSchema / getStateSchema /
getEffectSchema, getComponentInfo, snapshotState, restoreState,
exportTrace, and clearLog (plus flush, a no-op — signal send is
synchronous). window.__lluiComponents holds every mount;
window.__lluiDebug points at the active one.
Binding/mask/scope introspection (
getBindings,whyDidUpdate,decodeMask, effect timelines, time-travel) are legacy-runtime concepts the signal runtime does not implement — they are not on__lluiDebug.
MCP server: @llui/mcp
@llui/mcp exposes the debug API as MCP tools so an LLM can call them
directly. The Vite plugin auto-detects the package and spawns the
server alongside the dev server — one pnpm dev starts everything.
pnpm add -D @llui/mcp
// vite.config.ts
import { defineConfig } from 'vite'
import llui from '@llui/vite-plugin'
export default defineConfig({ plugins: [llui()] })
Point your MCP client at the HTTP endpoint:
// .mcp.json (Claude Code)
{
"mcpServers": {
"llui": {
"type": "http",
"url": "http://127.0.0.1:5200/mcp"
}
}
}
The MCP protocol runs on POST /mcp; the browser-relay WebSocket bridge
shares the port via upgrade on /bridge.
Stdio mode
Older MCP clients that spawn servers over stdio can run the CLI directly:
{
"mcpServers": {
"llui": {
"command": "npx",
"args": ["llui-mcp"]
}
}
}
In stdio mode, the server stands up its own bridge on port 5200. Set
mcpPort: 5200 in the Vite plugin so it wires to the externally-managed
server instead of spawning its own:
export default defineConfig({ plugins: [llui({ mcpPort: 5200 })] })
Tool catalog
Every @llui/mcp tool is named llui_*. Categories below; each name is
what you call from the LLM client.
State. llui_get_state, llui_describe_state, llui_search_state,
llui_diff_state, llui_assert.
Messaging. llui_send_message, llui_eval_update,
llui_validate_message, llui_list_messages, llui_list_effects,
llui_get_message_history, llui_search_history, llui_clear_log.
Components. llui_component_info, llui_list_components,
llui_select_component.
Snapshots and traces. llui_snapshot_state, llui_restore_state,
llui_export_trace, llui_replay_trace.
Source, tests and lint (no running app required). llui_find_msg_producers,
llui_find_msg_handlers, llui_run_test, llui_lint,
llui_compiler_diagnostics, llui_static_show_compiled,
llui_static_collect_paths.
Browser (CDP transport only). llui_screenshot, llui_a11y_tree,
llui_console_tail, llui_network_tail, llui_uncaught_errors,
llui_browser_close.
SSR (requires @llui/vike). llui_ssr_render, llui_hydration_report.
Notebook (devmode-annotate). llui_list_notes, llui_read_note,
llui_capture, llui_list_sessions, llui_current_session,
llui_rotate_session, llui_queue, llui_claim_note,
llui_reply_to_note.
Eval. llui_eval (arbitrary JS in page context with observability envelope).
For exhaustive parameter shapes, see the @llui/mcp API
reference.
llui_lint: idiomatic-code checks without a build
When you ask an LLM to write or edit LLui code, call llui_lint to
check the result against the compiler's signal lint rules. The tool
takes a single path argument (an absolute .ts/.tsx file on the dev
machine) — there is no source or exclude parameter:
// LLM tool call
{ "tool": "llui_lint", "args": { "path": "/abs/path/to/src/Counter.tsx" } }
Returns { file, score, violations, summary }. Each violation is
{ rule, message, line, column, fix? }, and score is
max(0, 20 - violations.length) (20 = clean). The same checks run
through the Vite plugin in dev — llui_lint gives the LLM an interactive
feedback loop to self-correct between generations. For a whole-directory
scan use llui_compiler_diagnostics.
What it catches: the signal lint rules — peek-in-slot,
operator-on-signal, pure-derive-body, no-node-construction-in-body,
controlled-input, at-after-map / prefer-at-over-map,
exhaustive-update, async-update, event-handler-casing, attr-name,
empty-props, agent-annotation-syntax, tag-send-drift, and the
shared convention checks.
Troubleshooting: llui-mcp doctor
If a tool call returns bridge-unavailable or Claude can't talk to a
running app:
npx llui-mcp doctor
It checks, in order:
- Is the active-marker file at
node_modules/.cache/llui-mcp/active.jsonpresent? - Is the marker JSON parseable?
- Has the Vite plugin stamped its
devUrlinto the marker? - Is the bridge port listening on 127.0.0.1?
- Is the PID recorded in the marker still alive?
Each check prints ✓ or ✗ with a one-line detail. Exit code is 0
when everything passes, 1 when any check fails.
Compile-time checks: @llui/compiler
The compiler itself enforces the signal lint rules as build errors,
not lint warnings — the same set llui_lint runs, plus the shared
cross-file / agent / convention checks. They cover reactivity misuse
(peek-in-slot, operator-on-signal, pure-derive-body,
no-node-construction-in-body), controlled inputs, exhaustive update(),
async update(), event-handler casing, attribute naming, malformed agent
annotations (agent-annotation-syntax), tagSend variant lists that
disagree with their handler (tag-send-drift), and more.
There is nothing to configure: the rules fire automatically through
@llui/vite-plugin, which surfaces them via
this.error() so the build fails until they're fixed. LLM-generated
code routinely ignores lint warnings, so the rules are deliberately
non-bypassable.
See the @llui/compiler API reference for the full
rule list.
Reading compiler diagnostics
The @llui/vite-plugin surfaces compiler diagnostics through Rollup's
warning channel (and this.error() for severity error). In
vite build output each line is formatted as:
[plugin llui] [<rule-id>] <relfile>:<line>: <message body>
The <relfile>:<line>: prefix is embedded in the message body
itself, so it survives reporters that drop the structured loc
field (Rolldown's build reporter does, most do in non-dev mode).
Click-through in iTerm, jump-to-line in IDE problem panels, and
grep are all viable.
Under the chunked-mask reactivity model there is no path ceiling and
no whole-state fallback: when the compiler can't statically trace an
accessor into a narrower dependency set, the binding simply stays
maximally reactive (it reads the relevant chunk-set), and correctness
is always preserved. When a rule does fire, the message carries the
rule id, the offending location, and a remediation hint — usually one
of: rename a binding, hoist a declaration, switch to a documented
composition primitive, or extract a same-module function. The full
list of active rules lives in the
@llui/compiler API reference.
Trace export and replay
Every dispatched message and resulting state is recorded. Export a trace and replay it inside a test:
const trace = __lluiDebug.exportTrace()
// or via MCP:
// { "tool": "llui_export_trace" }
// test
import { replayTrace } from '@llui/test'
import { Counter } from '../src/Counter'
await replayTrace(Counter, trace) // asserts every recorded state matches a fresh replay
Combined with snapshotState / restoreState, this lets you isolate a
regression in a long session and pin it as a deterministic test case.
replayTrace validates the trace before it runs a single reducer step: an
lluiTrace version it does not support, or a component that is not the one
being replayed against, throws a version/identity error naming both sides —
so a mispaired trace never surfaces as a state diff that looks like a bug in
your update(). A trace with no component field at all (an older export)
is replayed with a warning rather than rejected.
When to use which
| Situation | Tool |
|---|---|
| Quick poke at a running app | __lluiDebug.* in DevTools |
| LLM-assisted debugging session | @llui/mcp over MCP |
| Verify generated code is idiomatic | llui_lint (MCP) or vite build |
| Reproduce a session bug as a regression test | exportTrace + replayTrace |
| Catch anti-patterns in CI | @llui/compiler (build errors) |
| Integration with the bridge isn't working | npx llui-mcp doctor |