Pair-program against a **running** re-frame2 application via its Tool-Pair contract — attach to a live shadow-cljs nREPL, inspect a frame's app-db, dispatch events, hot-swap handlers, read the trace stream and per-frame epoch history, time-travel with `restore-epoch`, and replay a retained epoch strictly with `replay-epoch`. Use when the user is operating on (or wants to operate on) a live runtime they have running locally. **Do not use** for static spec reading, architecture questions, design discussion, or ordinary source edits when no runtime is involved — those belong to `skills/re-frame2/` (authoring) or direct spec reading. See `references/vocabulary.md` for the surface glossary; vocabulary matches alone do not justify activation.
Resources
13Install
npx skillscat add day8/re-frame2/re-frame2-pair Install via the SkillsCat registry.
re-frame2-pair
Pair-program on a live, running re-frame2 application (a browser tab behind shadow-cljs watch): help the developer understand, debug, and modify it by operating on the live runtime, not just reading source.
Router skill. Trigger-time guard rails are below; operational depth (op catalogue, recipes, error handling, hot-reload protocol, v1-migration notes) lives in references/, loaded on demand.
One runtime — the browser heap on the other end of the nREPL connection. Every op this skill teaches or allow-lists reads and writes that heap and nothing else. Story variants are no exception: a variant is an ordinary frame in the same heap, so you enumerate and run it through eval-cljs over re-frame.story/* and then address it with the ordinary frame tools (references/stories.md). Never start or call a second MCP server mid-session to reach a runtime — a separate process has a separate registry and a separate app-db, so a keyword that exists in both is two different objects, and composing reads across them produces confident nonsense. Explicitly headless, no-browser Story work is a different task with a different host: route it to `tools/story-mcp/README.md` and the authoring skill, and don't run it from inside a live pair session.
The three primitives
Your agency runs through three coupled primitives, all part of re-frame2's Tool-Pair contract:
- The REPL — a shadow-cljs nREPL session connected to the browser runtime; ClojureScript forms evaluate against the real app.
- The trace stream —
(rf/register-listener! :trace id cb)for live trace events;(re-frame.trace.tooling/trace-buffer frame-id)for the retain-N ring. (Listeners attach through the stream-parameterized facade verb — the 2-argre-frame.trace.toolingfn behind it is an implementation seam, not a second spelling.trace-buffer, by contrast, IS a same-signaturerf/re-export, and it exists on BOTH platforms — in acljs-evalform spell it fully qualified asre-frame.core/trace-buffer(or the tooling home), because the eval namespace carries norfalias.)trace-buffertakes the frame-id first ((trace-buffer frame-id)cascade bundles,(trace-buffer frame-id opts)filtered) — a missing/destroyed frame-id silently returns[], so never pass an opts map as the sole arg. This skill registers exactly one trace listener (id:re-frame2-pair) so tools coexist. - The epoch history —
(rf/epoch-history frame-id)returns the per-frame ring of:rf/epoch-recordvalues, each carrying the cascade's:db-before,:db-after,:trace-events, and the structured:sub-runs/:renders/:effectsprojections.(rf/register-listener! :epoch id cb)is the assembled-stream listener.
Every op eventually becomes a short ClojureScript form evaluated through the REPL, usually against a helper in the re-frame2-pair.runtime namespace the consumer app preloads (see §Setup).
Setup — preload re-frame2-pair.runtime
The skill's helper namespace ships into the app via shadow-cljs's :devtools :preloads mechanism (separate from Xray's devtools preload and true-inline [data-rf-xray-host] panel contract). The re-frame2-pair preload is required; there is no per-session cljs-eval inject fallback. When discover-app can't find the marker it runs a diagnostic ladder; the normal missing-preload verdict is :reason :runtime-loaded-but-preload-missing. See references/errors.md §discover-app preload-failure ladder for the full reason set and each recovery.
The skill installs from a repo checkout (not npm — see the README's Install / `docs/LOCAL_DEV.md`), so the preload comes off the linked skill's own preload/ directory. Point the consumer app's shadow-cljs :source-paths at it — the two-line shadow-cljs.edn change:
{:source-paths ["src"
"<abs>/skills/re-frame2-pair/preload"] ;; add this (the linked skill's preload/)
:builds
{:app {:devtools {:preloads [re-frame2-pair.runtime]}}}} ;; …and thisThe re-frame2-pair.runtime namespace is separate from the MCP server (@day8/re-frame2-pair-mcp) — the MCP server does NOT ship preload/, so wiring only the server leaves discover-app failing with :runtime-loaded-but-preload-missing. (Once the @day8/re-frame2-pair package is published to npm, npm install -D @day8/re-frame2-pair and point :source-paths at node_modules/@day8/re-frame2-pair/preload instead — see `docs/LOCAL_DEV.md`.)
Verify by running discover-app — success is {:ok? true :build-id ... :debug-enabled? true :frames [...]} plus other health slots. A missing preload returns {:ok? false :reason :runtime-loaded-but-preload-missing :hint "..."}; report the hint verbatim (fixable in seconds). The other ladder rungs (:build-not-running / :no-runtime-connected / :nrepl-unreachable, plus the blanket :runtime-not-preloaded degradation fallback) each mean a different fix — see references/errors.md §discover-app preload-failure ladder.
The epoch surfaces also need day8/re-frame2-epoch on the app's classpath, with re-frame.epoch required at boot. Neither the preload nor the MCP server brings it and discover-app does not check for it, so a core-only app passes discover-app, then every epoch read (watch-epochs, trace-window, (rf/epoch-history …)) comes back [] and restore-epoch / replay-epoch refuse — see references/errors.md §Tool-envelope refusals for its one loud symptom, :rf.error/epoch-artefact-missing.
Registering the MCP server takes a fresh session, not --continue. Wiring @day8/re-frame2-pair-mcp into the agent host (claude mcp add, or editing settings.json) only takes effect in sessions that start afterwards — a --continue'd session won't surface the tools even though claude mcp list reports Connected. Exit and start a new session after registering (also after rebuilding out/server.js).
The v1→v2 migration boot-smoke needs only a browser-console read, not this MCP — see `skills/re-frame-migration/references/runtime-smoke-test.md`. Reserve the full pair MCP for the deeper interactive debugging once the boot-smoke flags something.
Cardinal rule — two modes of changing the app
- REPL changes (hot-swap a handler, evaluate a form, reset a frame's
app-db) are ephemeral — survive hot-reloads of unaffected namespaces, lost on full page reload. Use for probes, experiments, throwaway fixes. - Source edits (
Edit/Write) are permanent. Capture your probe's current value before the edit (that's thebaseline); after the edit you must run the hot-reload coordination protocol (tail-buildwith the probe and that pre-editbaseline) before dispatching or tracing, or you'll interact with pre-reload code and get misleading results. The pre-edit baseline is what makes a fast reload — one that lands beforetail-build's first sample — read as success instead of a spurious timeout.
Know which mode you're in and why. Strict source-edit protocol: references/ops.md §Hot-reload coordination.
Connect first, every session
Before any other op, run discover-app (mcp__re-frame2-pair__discover-app). It locates the shadow-cljs nREPL port, connects, switches the session to :cljs mode for the running build, verifies re-frame2 is loaded with interop/debug-enabled? true, and confirms the re-frame2-pair.runtime preload landed (see §Setup).
Connect once — the resolved build sticks. A successful discover-app records the resolved build as the session-sticky default on the connection, for every resolution path — auto-selected single build, explicit :build, and :port. After one discover-app, call every other tool (orient, read-dom, read-ui, snapshot, get-path, …) with no build arg and it targets the resolved build — even with several builds running. Pass build again only to switch builds (an explicit :build on any later call wins and re-sticks). The sticky default resets on nREPL reconnect (a shadow restart); the next discover-app re-establishes it.
Arg forms (don't guess). Each arg has one expected shape:
| Arg | Form | Examples |
|---|---|---|
build |
bare build id; a leading colon is also tolerated. Omit it when one build is running — discover-app auto-selects it | "examples/standard-epochs" or ":examples/standard-epochs" — identical |
port |
integer — the port from the browser URL | 8031 — see Connecting from a URL below |
frame / frames |
keyword with the colon | ":rf/default", ":app/main", [":rf/default" ":rf/xray"] |
Single-build auto-selection / connecting from a URL. You usually don't pass build — one running build auto-selects (several → it errors with the running-builds list, never a silent guess). When you know only the open tab's URL, pass discover-app {port: 8031}: the server reads the :dev-http map and resolves the build served on that port (no build maps to it → :reason :port-unresolved, no silent fallback; an explicit :build wins if you pass both). The port-resolved build sticks like every other path. discover-app reports every build/frame id as a full keyword in the canonical EDN — read the EDN text for the id exactly as you'd type it back into a :frame arg (the :structuredContent JSON view strips the colon, a documented lossy projection).
Read the :freshness token before you trust a read. Every :ok? true discover-app payload carries :freshness {:liveness <verdict> :hint <str> ...}. Pattern-match :liveness first: :fresh (read away), :stale-build (tab serving OLD code — the :hint names the URL to reload), :no-runtime (no live CLJS runtime — reads come back blank; :hint names the URL to reload then re-run discover-app), :unknown (build-worker state unreadable — not a green light; usual cause is a zombie shadow-cljs JVM, so the :hint steers to npx shadow-cljs stop → one watch → reload → re-discover).
You cannot reload a browser yourself — on a non-:fresh verdict, relay the :freshness :hint to the user as the single next step rather than firing reads that return blank.
On any failed precondition, discover-app returns structured edn ({:ok? false :reason :runtime-loaded-but-preload-missing} or another ladder rung: :build-not-running / :no-runtime-connected / :nrepl-unreachable). Report the failing check verbatim; do not guess workarounds. See references/errors.md for reasons + recoveries.
Port + build discovery are automatic. On the first tool call the server discovers the live shadow-cljs nREPL itself and absorbs shadow restarts transparently; the resolved build sticks. Only reach for manual resolution on :reason :port-unresolved (read the app's shadow-cljs.edn :dev-http yourself to correlate the URL port to its build, then pass discover-app {build: ...}). The full discovery cascade, build-id resolution rules, and the --port-file / SHADOW_CLJS_NREPL_PORT overrides live in references/mcp-transport.md — rarely needed.
The nREPL session persists between turns. A full page refresh drops the runtime, but the preload re-installs it on the next bundle load — no manual reconnect. Every op checks the load-time marker (js/globalThis.__re_frame2_pair_runtime) first; if missing, the op refuses with the runtime-side :runtime-not-preloaded hint pointing here. (That per-op check is distinct from discover-app's richer ladder, which on the same missing-marker condition reports the more precise :runtime-loaded-but-preload-missing — see references/errors.md.)
For a refresher on the MCP surface before the first real op, optionally call get-re-frame2-pair-instructions — inline onboarding text (six routing rules for which tool to reach for, then the EDN posture, tagged-mutation conventions, the wire pipeline) with no nREPL round-trip. It does not list the tools; tools/list already gave you every descriptor. Its routing preamble states the same eval-cljs rule as §Style guidance below — reach for a typed tool whenever one fits the gesture, and treat eval-cljs as first-class for the long tail no typed tool covers — so read the two as one rule restated, not as competing advice.
Orient before you drill
Your read order, every session: discover-app → orient → a targeted slice.
After discover-app connects, your first read is always orient (mcp__re-frame2-pair__orient {}) — never a snapshot. orient returns a compact, one-round-trip app-shape summary: liveness, the app frames (reserved :rf/* tool frames excluded), each app frame's top-level app-db keys only, registry counts, and the navigable event / sub / fx / machine ids. Bounded by construction — it hands you the map without pulling any bulk, and omits the :rf/xray tool frame's contents.
Only after orient do you drill — into slices, never whole frames:
| You want | Use |
|---|---|
| one sub's current value | read-sub {sub: "[:cart/total]"} |
| one app-db path | get-path {path: "[:cart :items]"} |
| a bounded sub-tree | snapshot {path: "[:cart]"} (the snapshot tool defaults to :summary — top-level shape only; a path narrows it further) |
Never read a whole frame to orient. A snapshot {path: "[]"} (full, unsliced) or an eval-cljs form that derefs an entire frame's app-db can be enormous — especially the reserved :rf/xray tool frame: its app-db is Xray's entire working set (the epoch ring with each epoch's :db-before / :db-after, the trace buffer, diff projections, panel state) and grows without bound. A full read of it has overflowed past 100K tokens in one call. You essentially never read the :rf/xray frame at all — pair against the app frame (whatever id the app registered, e.g. :app/main, or :rf/default if the app chose that).
Mental model: breadth-first shallow (orient) → depth-first narrow (read-sub / get-path / snapshot {path}). snapshot is a drill-in tool you hand a path, not the way you take in an app.
Multi-frame model — set the operating frame
re-frame2 supports multiple, named frames (Spec 002). Most apps run one app frame (registered at the root — whatever id the app chose, e.g. :app/main); larger apps run several. The public address is the frame (EP-0023) — image -> frame -> event stream: target a frame id in a single process-local frame-id space, no realm/container coordinate. Every read/write op resolves an operating frame through a four-tier cascade: per-call frame arg (tier 1) → session pin (tier 2) → the sole registered app frame (tier 3) → nil/ambiguous (tier 4). Tier 3 is unique resolution, not synthesis. Framework-reserved :rf/* tool frames (Xray's :rf/xray, SSR/stories slots) are excluded from the ambiguity count, so a single-app session also running Xray auto-resolves to its one app frame. (:rf/default, if an app registers it, is an ordinary app frame — no framework privilege, still counted.) Full detail: references/ops.md §Frames.
Set the session pin with the dedicated operating-frame tools — three MCP tools surface tier 2 directly, no eval round-trip: set-operating-frame {frame: ":foo"} (pin — the escape from the tier-4 refusal; validates the id, returns the {:frames :selected :operating} triple), reset-operating-frame {} (clear), get-operating-frame {} (read; :operating nil means ambiguous). These three are NOT subject to the :ambiguous-frame refusal — they resolve it.
When the operating frame is ambiguous (two-plus app frames, no pin), every other frame-targeted op refuses with :ambiguous-frame rather than guess — a write into the wrong frame is unrecoverable without restore-epoch. Reads refuse too (the validated read helpers return :reason :ambiguous-frame rather than silently reading :rf/default). Mirrors Spec 002 §Frame presets / lifecycle convention.
Where the depth lives — loading map
Read the leaf matching the task. Most references are ≤250 lines; the two catalogue leaves (ops.md, recipes.md) run longer. Screen reads — what is on screen and why it rendered — are their own leaf (screen-reads.md), so an ordinary read/dispatch/trace session never loads them.
| Task shape | Reference |
|---|---|
| Pick a structured op (read, validated sub-read, write, dry-run, trace, signal recording, blocking wait, hot-reload, time-travel) | references/ops.md |
Pick a screen-read op (DOM source bridge, read-dom vs read-ui, Fresco evidence) |
references/screen-reads.md |
| Orient on an unfamiliar app in one call, or read a single sub's validated value | orient / read-sub — see references/ops.md §Read (arg signatures in references/mcp-transport.md §MCP tool reference) |
| Find out which registrations / image a specific frame actually runs (the multi-image / Story case where the same id resolves differently per frame), with per-frame provenance | describe-image — see references/ops.md §Frames |
| Read what's actually on screen (rendered content + producing view entity) | read-ui / read-dom — see references/screen-reads.md §View → rendered content |
Ask which Fresco boundaries are mounted right now (read-mounted-boundaries), or which boundaries read a subscription you can name (read-read-attribution). A boundary here has no view name — the runtime mints no identity, so it is keyed by its read set. Reagent and UIx apps answer :evidence-tier-unavailable on all three; every other op in this skill works on them unchanged |
see references/screen-reads.md §Fresco evidence |
Ask which of a boundary's reads moved when it rendered and you didn't expect it to (explain-render) — the proven half, beside retained runs offered as leads |
see references/screen-reads.md §Fresco evidence |
| Simulate an event's consequence WITHOUT committing it (no fx fire) | dispatch-dry-run — see references/recipes.md §"What would this event do?" |
| Record signals while the human interacts, or block until a condition lands | record / read-recording / watch-until — see references/ops.md §Signal recording |
| Run a named procedure the user asked for ("why didn't my view update?", post-mortem, experiment loop, etc.) | references/recipes.md |
| Drive a Story variant in the app you have open — enumerate the browser's own registry, run a variant, then read/dispatch/trace/diff it as an ordinary frame (the variant id is the frame id) | references/stories.md |
Decode a deduped wire payload (:rf.mcp/dedup-table) or pick the right size-conscious arg (max-tokens, path, mode, dedup, elision, limit/cursor, cache, max-entries) |
references/wire-size-budget.md |
Translate a structured {:ok? false :reason ...} to plain English; suggest the recovery |
references/errors.md |
| Edit source, then wait for the browser to pick up the new code | references/ops.md §Hot-reload coordination |
Map a v1 (re-frame-pair) surface to its v2 equivalent (or know it has none) |
references/ops.md §Dropped from v1 |
| Install/configure the persistent-connection MCP server | references/mcp-transport.md |
Load at most two references for a single task. Wanting three means the request spans concerns and should be broken up.
Style guidance
- Read before you write —
orientfirst, then drill into slices. Your first read each session isorient, never a whole-frame read; ground a hypothesis by drilling into a slice (read-sub/get-path/snapshot {path}). See Orient before you drill for the rule and the:rf/xrayoverflow. - Prefer a structured op when one FITS the gesture;
eval-cljsis the workhorse for the long tail, not a last resort. Dedicated tools (orient,read-sub,get-path,read-ui,dispatch, …) give a validated, elided, single-round-trip answer for the gesture they own; epoch forensics, arbitrary-selector DOM reads, cross-referencing, and recovery are first-classeval-cljswork — see recipes.md §eval-cljs is the workhorse. (Privacy caveat: raweval-cljsis un-elided — see the privacy bullet below.) - Hypothesis-test through
dispatch-dry-run, not a livedispatchor throwawayeval-cljshandler. For "test this handler" / "try a dispatch" / "what would happen if…", reach fordispatch-dry-runfirst: it runs the whole cascade (reducer, interceptors, schema, machine transitions, sub-runs, renders) without committing — no fx execute, the framework auto-rolls-back app-db. It is the safe primitive for this gesture;dispatchand raweval-cljsare not. Dry-run targets a registered event, so to probe a throwaway handler: register it witheval-cljs((rf/reg-event :exp/probe …)), thendispatch-dry-run {event: "[:exp/probe …]"}— the rollback still applies, leaving no trace. See recipes.md §Experiment loop and §"What would this event do?". - WARNING — a
reg-eventhandler returning{:db <bare-map>}REPLACES app-db wholesale (does NOT merge), so a throwaway probe driven by a livedispatchnukes the entire frame's app-db, unrecoverable withoutrestore-epoch. Two safe paths: preferdispatch-dry-run(rolls back), or if you must commit return{:db (assoc db …)}from the livedbcofx — never a bare literal map. Full treatment: recipes.md §Experiment loop. - Keep it in re-frame2's vocabulary. Dispatch, reg-event, reg-sub, reg-machine, frame, epoch — speak the app's language. Avoid
reset!of a frame's app-db except when surgically needed, and say so when you do. - On ambiguous/failing tool resolution, read the project config (
shadow-cljs.edn,deps.edn) rather than bouncing off the tool. "Which build is this port?" / "where does this artefact live?" usually sits in the source config in plain sight. - Experiment, don't speculate. When an answer isn't obvious, probe at the REPL against live data.
- Validate before proposing. Compose a hot-swap/suggestion form and run it against current state first.
- Narrow detail as you go. Summaries first; drill into a specific epoch, diff, sub-run, or render entry on request.
- Always resolve UI references to source first. When the user mentions a button, view, panel, or "the thing I clicked", run
dom/source-atbefore speculating. Reportingre-com/button at app/cart/view.cljs:84grounds the conversation in a file the user can open; "probably the Save button somewhere in the profile view" doesn't. - Surface restore limits. Before any time-travel experiment, walk the cascade's effects and tell the user which effects already fired and cannot be reversed.
- Use the assembled epoch stream by default; reach for the raw trace stream when you need detail the projection drops.
:sub-runs,:renders,:effectsare the routing surface;:trace-eventsis the escape hatch for detail the projection omits (e.g. per-interceptor timing, or the raw fx:argsthe:effectsprojection redacts off-box). - One trace listener per skill. This skill registers one listener (
:re-frame2-pair) and one epoch listener (:re-frame2-pair-epoch). Multi-tool coexistence is the default — don't worry about other listeners; per Spec 009 §Listener ordering, ordering is not contract. - Structured MCP reads elide by default; raw
eval-cljsdoes NOT. Per Spec 009 §Privacy, the structured read tools (snapshot,get-path,read-sub,trace-window,watch-epochs,dispatch-dry-run, and the recorders) force wire-boundary elision server-side under the--allow-sensitive-readsgate (OFF by default) — sensitive →:rf/redacted, large →:rf.size/large-elided, epoch records additionally route throughproject-egress(the one record-level egress door, which recognises an epoch record by its stamped:kind). So structured reads are safe to fire by default. Buteval-cljsis default-ON (governed only by--no-eval) and NOT governed by this gate — it returns the form's value without running the elision walker, so a raw(re-frame2-pair.runtime/snapshot)/(rf/epoch-history …)can ship verbatim app-db / trace / epoch state — secrets included. This is the raw-eval carve-out: don't reach for raw eval to read a sensitive path / sub / trace / epoch when a structured tool fits — reserve it for forensics, cross-referencing, and recovery. Full mechanism in references/vocabulary.md §Privacy posture. - Route named writes through the dedicated, gated tools. Time-travel undo and state injection have dedicated tools —
restore-epochandreplace-app-db— both allow-listed and the canonical path (validate, append a synthetic undoable epoch,tap>, return a:cascade-summary). Both are gated by the server's--allow-writesflag (default OFF); the server's gate, not the allow-list, is the write boundary. Raw eval of a write form ((rf/restore-epoch! …)/app-db-reset!) is the backstop, not the default — reach for the dedicated tool first. Full detail: references/ops.md §Time-travel. - Replay a retained epoch through
replay-epoch, never by re-typing its payload. To re-drive a recorded event faithfully, pass the epoch id alone: the runtime resolves the raw record in-process and re-dispatches it under:strictwith the recorded:rf.cofxand both recorded override maps, so the generator is never consulted and a fact the record lacks fails loud (:rf.error/missing-required-cofx). Thedispatch {replay: true, cofx: …}recipe needs payloads the projected epoch pages only ever show as:rf/redacted. Replay does not restore first (rewind withrestore-epochwhen the start state matters), fires effects again, and records a new epoch. It carriesdispatch's authority — not--allow-writes-gated. Full detail: references/ops.md §Time-travel.
When to also open Xray
A re-frame2-pair session and a running Xray panel are complementary surfaces over the same trace bus + epoch history. re-frame2-pair owns the driving (dispatch, hot-swap, restore-epoch); Xray owns the seeing (visual reading of what just happened across its Dynamic event-spine tabs and Static registry-browse tabs — skills/re-frame2-xray/ is the canonical source for Xray facts). Reach for Xray alongside re-frame2-pair when:
| re-frame2-pair just did | Open Xray to … |
|---|---|
Rewound to an earlier epoch via restore-epoch |
Scrub the bottom-rail time-travel scrubber to inspect adjacent epochs visually; pin slices via the app-db tab's inline diff. |
| Dispatched into a cascade you don't fully understand | The Epoch tab lands on the latest cascade and shows the dispatch-id tree. |
| Hot-swapped a sub or reg-event handler | Watch the Epoch SUBSCRIPTIONS step recompute the invalidation chain (:cart/total ← :cart/items ← [:cart :items]). |
| Stepped into a machine transition | Open the Machine tab / Machine Inspector for the state-chart view with transition history. |
| Triggered a schema violation | Schema violations surface inline on the Epoch tab (and as an L2 pink-wash) with recovery mode + source coord. |
Authoring-side guidance for getting Xray mounted (preload, layout host, suppress-auto-open knob, popout, host-CSS-variable resize) lives at `skills/re-frame2/references/tooling/xray.md`. When advising a user mid-session on which panel to look at, route them there for the mount-side detail; this skill stays focused on the driving side.
Deep-dive content (full API reference, EP design rationale, spec corpus, migration guide) routes through `SKILL-REDIRECT.md` at the repo root. Full skill-disambiguation matrix (when to use which skill) lives at `skills/README.md` §Skill routing — single source.