Construction Prompts (AI Agent Templates)¶
Type: Construction Prompts Per-kind AI-scaffolding templates for new code in the re-frame2 pattern. Sibling to MIGRATION.md (which covers upgrades of existing code).
CP-10 (story / variant / workspace) is post-v1; its sketch lives in 007-Stories.md (CP-10 sketch) and the full template lands here when re-frame.stories ships.
Purpose¶
MIGRATION.md is the AI prompt for upgrading old re-frame code. Construction Prompts is the AI prompt for creating new code in the re-frame2 pattern. Where MIGRATION rewrites existing shapes, Construction Prompts scaffolds shapes that don't yet exist.
This artefact is intended to be:
- Per-kind. Separate templates for events, subscriptions, schema-bound views, state machines, features, routes, effects.
- Self-contained. Each prompt provides enough context for an AI to scaffold the kind without needing to read the full Spec set.
- Shape-aware via the host's idiom. The pattern requires shape description; the mechanism is host-specific. Dynamic in-scope hosts (CLJS + Malli, Squint + Zod): the prompts attach
:specmetadata referring to a schema. Static in-scope hosts (TypeScript, Melange / ReScript / Reason, Fable (F#), Scala.js, PureScript, Kotlin/JS): the prompts emit type-annotated registrations whose shapes the compiler enforces. Either way, an AI reading the artefact has a description of the shapes; the prompts adapt to the host. - Worked-example-heavy. Each prompt ends with one or two complete, runnable examples.
- Aligned with the Goals in 000 and the nine AI-first properties in Principles.md. A construction-prompt-generated artefact is, by construction, AI-first.
Shared pre-flight (applies to every CP)¶
Every CP below begins with the same mechanics; rather than restate them in each, treat the following as a shared preamble. Each CP's "Pre-flight checks" section calls out only the delta — the kind-specific naming convention or extra check — on top of these.
- Choose a namespaced id. Lowercase, kebab-case. The id-prefix matches the feature (per Conventions §Feature-modularity prefix convention).
- Verify the id is unused. Query the registry via the public registrar query API for the relevant kind (e.g.,
(rf/handlers :event),(rf/handlers :sub),(rf/handlers :fx),(rf/handlers :view),(rf/handlers :route)). - Consult registered schemas (
(rf/app-schemas),(rf/app-schema-at <path>)) so the new artefact aligns with shapes already in use.
Catalogue¶
Each entry below is one CP:
CP-1. Add an event handler¶
When to use this prompt: the user wants the app to react to something — a button click, a server response, a timer tick, a child machine sending a message. If the reaction modifies state only, prefer reg-event-db. If it also produces side-effects (HTTP call, navigation, dispatch chain, local-storage write), use reg-event-fx.
Pre-flight delta (in addition to the shared preamble above):
- Id-shape convention:
:feature/verb-nounor:feature.subfeature/verb-noun. Examples::auth/login,:auth.password/reset,:cart.item/remove. The relevant registry kind is:event. - Call-shape convention (per Principles §Name over place and 002 §Routing):
[<id>]for trivial events,[<id> <single-scalar>]for single-argument events,[<id> {<key> <val> ...}]for multi-argument events. Multi-positional[<id> a b c]is accepted by the runtime; the linter nudges new code toward the map shape. - Schema-bound paths. If the event reads or writes a schema-bound
app-dbpath, your handler must produce schema-compliant output (validation runs in dev).
Template — reg-event-db (state-only):
(rf/reg-event-db :feature/verb-noun
{:doc "One-sentence what-and-why."
:spec EventSchema} ;; optional Malli schema for the event vector
(fn handler-feature-verb-noun [db [_ {:keys [field-1 field-2]}]]
;; Pure: read db and event payload, return the new db.
;; Multi-arg events take a single map payload; destructure named keys.
;; No side-effects. No dispatching from inside the handler — return effects from reg-event-fx if needed.
(assoc-in db [:feature :path] field-1)))
Template — reg-event-fx (with effects):
(rf/reg-event-fx :feature/verb-noun
{:doc "One-sentence what-and-why."
:spec EventSchema}
;; A positional interceptor vector goes here when needed; usually omitted.
(fn handler-feature-verb-noun [{:keys [db] :as cofx} [_ payload]]
;; Pure: read db and any injected cofx, return an effect map.
;; payload is a single scalar (for single-arg events) or a map (for multi-arg events
;; — destructure named keys: [_ {:keys [field-1 field-2]}]).
;; Effects are data; the runtime interprets them.
{:db (assoc-in db [:feature :loading?] true)
:fx [[:http {:method :get :url "/api/feature" :on-success [:feature/loaded]}]]}))
Naming the handler fn: match the id's path and verb. :cart.item/remove → handler-cart-item-remove. The name shows up in stack traces and tooling; anonymous lambdas hide.
Smoke test:
(deftest feature-verb-noun-test
(rf/with-frame [f (rf/make-frame {:on-create [:feature/initialise]})]
(rf/dispatch-sync [:feature/verb-noun "value"] {:frame f})
(is (= "value" (get-in (rf/get-frame-db f) [:feature :path])))))
AI-first checklist before declaring done:
- [ ] Id is namespaced and unused.
- [ ] Handler is pure (no side-effects in the body of
reg-event-db; effects are returned byreg-event-fx). - [ ]
:docis present and one sentence. - [ ] Shape is described by the host's idiom:
:spec(Malli/Pydantic/Zod) in dynamic hosts, a type definition in static hosts. If neither: shape conformance is checked by tests/fixtures. - [ ] Handler has a meaningful name (not
fn). - [ ] Smoke test passes.
- [ ] If the handler writes a schema-bound
app-dbpath (in a schema-bearing implementation), the test asserts the post-state validates.
Example — full worked artefact:
(ns my-app.cart.events
(:require [re-frame.core :as rf]
[malli.core :as m]))
(def CartItemRemoveEvent
[:tuple [:= :cart.item/remove] :uuid]) ;; [event-id item-id]
(rf/reg-event-db :cart.item/remove
{:doc "Remove an item from the cart by id."
:spec CartItemRemoveEvent}
(fn handler-cart-item-remove [db [_ item-id]]
(update-in db [:cart :items] (fn [items] (vec (remove #(= item-id (:id %)) items))))))
CP-2. Add a subscription¶
When to use this prompt: the user wants a derived view of app-db — a computed value views can read. A subscription is not a place to put side-effects; it is a pure function from state to value.
Pre-flight delta (in addition to the shared preamble above):
- Id-shape convention:
:feature/propertyor:feature/computed-value. Examples::cart/total,:cart/items-count,:auth/logged-in?. The relevant registry kind is:sub. - Call-shape convention (per Principles §Name over place and 002 §Routing, same as for events):
[<id>]for trivial subs,[<id> <single-scalar>]for single-argument subs ([:user-by-id 42]),[<id> {<key> <val> ...}]for multi-argument subs ([:items-filtered {:status :pending :limit 20}]). Multi-positional[<id> a b c]is accepted by the runtime; the linter nudges new code toward the map shape. - Decide the input. Either reads
app-dbdirectly (Layer 1 sub) or composes other subs (Layer 2 / signal-graph chained sub via:<-). - Check schemas. If the sub's return value has a registered schema (rare for layer-1, common for layer-2), align the output shape.
Template — Layer 1 (reads app-db directly):
(rf/reg-sub :feature/items
{:doc "All items currently in the feature's slice."}
(fn sub-feature-items [db _query]
(get-in db [:feature :items])))
Template — Layer 2 (chained via :<-):
(rf/reg-sub :feature/total
{:doc "Aggregate computed from :feature/items."}
:<- [:feature/items]
(fn sub-feature-total [items _query]
(reduce + (map :amount items))))
Template — multi-input chain:
(rf/reg-sub :feature/summary
{:doc "Joins items, user, and pricing rules to produce a display summary."}
:<- [:feature/items]
:<- [:auth/current-user]
:<- [:pricing/active-rule]
(fn sub-feature-summary [[items user rule] _query]
{:item-count (count items)
:discount-eligible? (and rule (>= (count items) (:min-items rule)))
:user-tier (:tier user)}))
Pattern-level discipline:
- Body is pure —
(state, query) → value. No side-effects, no mutation, no I/O. - Layer 2 subs don't read
app-dbdirectly. They compose layer-1 subs via:<-. (This keeps the signal graph topology static and queryable via(sub-topology).) - Sub computations should be fast. Heavy work belongs in event handlers that pre-compute and store in
app-db.
Smoke test (headless via compute-sub):
(deftest feature-total-test
(let [db {:feature {:items [{:amount 10} {:amount 25} {:amount 5}]}}]
(is (= 40 (rf/compute-sub [:feature/total] db)))))
AI-first checklist:
- [ ] Sub id is namespaced and unused.
- [ ] Body is pure.
- [ ] Layer-2 subs use
:<-chains; they don't readapp-dbdirectly. - [ ]
:docis present. - [ ] Smoke test runs headlessly via
compute-sub. - [ ] If the return value has a schema, the test asserts conformance.
Example — full worked artefact:
(rf/reg-sub :cart/total
{:doc "Sum of qty × price across cart items."}
:<- [:cart/items]
(fn sub-cart-total [items _]
(reduce + (map #(* (:qty %) (:price %)) items))))
CP-3. Add a registered effect¶
When to use this prompt: the user wants a side-effect — HTTP, local-storage, navigation, websocket message, timer, log. Effects are id'd, registered, and platform-tagged for SSR (per 011).
Pre-flight delta (in addition to the shared preamble above):
- Id-shape convention: a single namespaced keyword. Examples:
:http,:localstorage,:rf.nav/replace,:websocket/send. The relevant registry kind is:fx. - Decide the platform. Server-only? Client-only? Both? Default
#{:server :client}(universal) if absent — set explicitly to#{:client}if the fx genuinely cannot run server-side (DOM mutation,js/window,localStorage). - Identify the args shape. What does the effect map's value look like? Does it need a schema?
Template:
(rf/reg-fx :http
{:doc "Issue an HTTP request. On completion, dispatch :on-success or :on-error."
:platforms #{:server :client} ;; both server (SSR data fetch) and client
:spec [:map
[:method [:enum :get :post :put :delete]]
[:url :string]
[:body {:optional true} :any]
[:on-success {:optional true} [:vector :any]]
[:on-error {:optional true} [:vector :any]]]}
(fn fx-http [m args]
;; m: the dispatch envelope (the active frame, trace id, etc.)
;; args: the value the user put under :http in their effect map
(let [{:keys [method url body on-success on-error]} args
frame-id (:frame m)]
(-> (perform-http-request method url body)
(.then (fn [resp] (when on-success (rf/dispatch (conj on-success resp) {:frame frame-id}))))
(.catch (fn [err] (when on-error (rf/dispatch (conj on-error err) {:frame frame-id}))))))))
Pattern-level discipline:
- The handler is
(envelope-map, args) → side-effect. The args are data; the side-effect is performed at the boundary. - The handler is responsible for dispatching follow-up events (
:on-success/:on-error) on the originating frame so the state-change cascade resumes. - The handler may not modify
app-dbdirectly — only via dispatched events. - Server-only effects (
:platforms #{:server}) are skipped on the client; client-only on the server. The runtime emits:rf.fx/skipped-on-platformtraces so it's visible.
Test stub (id-valued override):
(rf/reg-fx :http.canned-200
{:doc "Test stub: every :http call resolves to a canned 200 response."
:platforms #{:client :server}}
(fn fx-http-canned-200 [_m args]
(when-let [on-success (:on-success args)]
(rf/dispatch (conj on-success {:status 200 :body "test"})))))
;; in a test
(rf/with-frame [f (rf/make-frame {:fx-overrides {:http :http.canned-200}
:on-create [:feature/load]})]
...)
The override seam is id-valued at the pattern level. The CLJS reference also accepts function values for one-off CLJS lambdas, but registered-fx-id is the portable form (works for SSR, serialises across the wire, listable in tooling).
AI-first checklist:
- [ ] Fx id is namespaced.
- [ ]
:platformsis set explicitly (don't rely on the default). - [ ]
:specdescribes the args shape. - [ ]
:docis present. - [ ] Handler dispatches
:on-success/:on-error(or equivalent) on the originating frame. - [ ] Handler does NOT mutate
app-db— that's events' job. - [ ] A registered test-stub fx exists with a stable id (e.g.,
:fx-id.canned) for tests.
Example — local storage:
(rf/reg-fx :localstorage/set
{:doc "Write a key/value pair to browser localStorage. Client only."
:platforms #{:client}
:spec [:map
[:key :string]
[:value :any]]}
(fn fx-localstorage-set [_m {:keys [key value]}]
(.setItem js/localStorage key (pr-str value))))
CP-4. Add a registered view¶
When to use this prompt: the user wants a UI component that renders state and dispatches events. Always prefer reg-view over plain Reagent fns for any view that may render under a non-default frame (which, with SSR-per-request, is most views).
Pre-flight delta (in addition to the shared preamble above):
- Id-shape convention:
:feature/component-nameor:feature.area/component-name. Plural component names for lists (:cart/items), singular for single-instance (:cart/total). The relevant registry kind is:view. - Identify the subscriptions the view will read. Each must be already registered (
(rf/handlers :sub)) or scaffolded as a sibling — if missing, invoke CP-2. - Identify the events the view will dispatch. Each must be already registered (
(rf/handlers :event)) — if missing, invoke CP-1.
Template — Form-1 (simple render fn):
(rf/reg-view ^{:doc "One-sentence what-and-why."
:spec [:cat :string :int]} ;; optional Malli schema for the props vector
component-name [label count-prop]
(let [items @(subscribe [:feature/items])] ;; frame-bound; resolves on the surrounding frame
[:div.feature
[:h3 label]
[:p (str "Count: " count-prop)]
(for [item items]
^{:key (:id item)}
[:div.item
[:span (:label item)]
[:button {:on-click #(dispatch [:feature/select (:id item)])} "Select"]])]))
Template — Form-2 (closure for once-on-mount setup):
(rf/reg-view component-name [label]
;; Outer body: runs once on mount. Use for setup that should fire once per
;; component lifecycle (e.g., dispatching an :on-create-style init event,
;; registering a frame, opening a websocket).
(dispatch [:feature/component-mounted])
(fn render-feature-component-name [label]
;; Inner render fn: runs every render.
(let [n @(subscribe [:feature/count])]
[:div label ": " n])))
Pattern-level discipline (per 004 and 011):
- Body is pure given the inputs (props + frame-bound subs).
- Output is serialisable data (hiccup, no embedded JS objects).
- No render-time side-effects — no
js/setTimeout, no DOM mutations, no fetch. - Side-effects belong in event handlers; the view dispatches.
Smoke test (headless render):
(deftest feature-component-name-renders
(rf/with-frame [f (rf/make-frame {:on-create [:feature/initialise]})]
(let [hiccup [component-name "test-label" 42]
html (rf/render-to-string hiccup {:frame f})]
(is (str/includes? html "test-label"))
(is (str/includes? html "Count: 42")))))
AI-first checklist:
- [ ] View id is namespaced and unused.
- [ ] All subs/events the view uses are registered.
- [ ] Body is pure; no render-time side-effects.
- [ ]
:on-click/:on-changelambdas dispatch named events (no inlineswap!/reset!). - [ ] Output round-trips through
render-to-string(smoke test passes both server-side and client-side). - [ ] If the props vector has a schema, validation passes for the test inputs.
Example — full worked artefact:
(rf/reg-view ^{:doc "Total cost and item count for the current cart."} summary []
(let [items @(subscribe [:cart/items])
total @(subscribe [:cart/total])]
[:div.cart-summary
[:span (str (count items) " items")]
[:span (str "$" (format "%.2f" total))]
[:button {:on-click #(dispatch [:cart/checkout])} "Checkout"]]))
CP-5. Scaffold a state machine¶
When to use this prompt: the user describes a multi-step interaction with discrete, named states — a login flow, a checkout wizard, a video player, a modal lifecycle, a websocket connection. If you can list the states and the events that move between them, you have a machine.
Key idea: the machine IS the event handler. A machine is registered as one reg-event-fx whose body comes from create-machine-handler. Sub-events route in: (rf/dispatch [:my/machine [:my-input arg ...]]).
Default form: named guards and actions in the machine's :guards / :actions maps. The transition table references guards and actions by keyword (:under-retry-limit, :clear-error); the bodies live in the machine's own :guards / :actions maps inside create-machine-handler. This is the default because the named id carries semantic meaning that visualisers, AIs, conformance fixtures, and humans all read; an inline (fn [snap ev] ...) is opaque to inspection. Inline fns are an escape hatch for trivial logic (one-liners with no branching), not the default form. See 005 §Inspectability bias. Resolution is machine-local — there is no global :machine-guard / :machine-action registry; cross-machine reuse is via Clojure vars referenced from each machine's map.
Pre-flight checks:
- Choose a machine id. Convention:
:feature.flow/machineor:feature/flow. Examples::auth.login/flow,:checkout/flow,:video-player/flow. - Verify the id is unused.
(rf/handlers :event)— the machine reuses the:eventregistry kind. (No matching:subregistration is needed: machines are read through the framework-registered parametric sub:rf/machine; see "Where state lives" below.)(rf/machines)enumerates already-registered machines specifically. - List the states. Discrete, named (
:idle,:submitting,:authed,:error-shown). - List the inputs (sub-events) that move between states. Each input triggers exactly one transition.
- Identify guards and actions; default to naming them in
:guards/:actions. Each guard(fn [snapshot event] boolean)and each action(fn [snapshot event] {:data {...} :fx [...]})is a key in the machine's:guards/:actionsmap (referenced from transitions by keyword). Inline only when the body is a single non-branching expression.
Where state lives. Every machine's snapshot lives at the runtime-managed path [:rf/machines <machine-id>] in the frame's app-db. For id :auth.login/flow, the snapshot is at [:rf/machines :auth.login/flow] and contains {:state ... :data ...}. You do not pick the path — create-machine-handler does not accept a :path key. Per-frame isolation is automatic: each frame has its own app-db and thus its own :rf/machines map. See 005 §Where snapshots live.
Reading the snapshot in views. The framework ships :rf/machine as a standard parametric sub. @(rf/sub-machine :auth.login/flow) returns the snapshot (sugar over @(rf/subscribe [:rf/machine :auth.login/flow])) — no per-machine reg-sub needed. Destructure inline, or write a derived sub :<- [:rf/machine <id>] for projections. See 005 §Subscribing to machines via sub-machine.
Strict encapsulation. Actions and guards see {:state :data} only — no :db, no cofx. Cross-cutting reads pass through the event payload; cross-cutting writes go via :fx [[:dispatch <named-event>]]. Action effect maps are {:data {...} :fx [...]} — symmetric with reg-event-fx's {:db :fx}. The named-bounce-event pattern is a feature, not a tax: it makes the cross-cutting concern visible in the trace, the registry, and 10x's event log (per 005 §Strict encapsulation).
Worked example — auth login flow (named guards/actions in :guards / :actions maps):
;; The machine — every guard and action is named in :guards / :actions and
;; referenced from the transition table by keyword. Resolution is
;; machine-local: the runtime calls (get-in spec [:guards :under-retry-limit])
;; etc. There is no global :machine-guard / :machine-action registry.
(rf/reg-event-fx :auth.login/flow
{:doc "Login flow: idle → submitting → authed / error-shown / locked-out."}
(rf/create-machine-handler
{:initial :idle
:data {:attempts 0 :error nil}
:guards
{:under-retry-limit
;; Has this login had fewer than 3 prior attempts?
(fn [data _event]
(< (:attempts data) 3))}
:actions
{:begin-submit
;; Clear the prior error and emit the HTTP request for credential check.
(fn [_data [_ creds]]
{:data {:error nil}
:fx [[:http {:method :post
:url "/api/login"
:body creds
:on-success [:auth.login/flow [:succeeded]]
:on-error [:auth.login/flow [:failed]]}]]})
:record-failure
;; Bump the attempts counter and surface a credentials error.
(fn [data _event]
{:data {:attempts (inc (:attempts data))
:error :credentials}})
:lock-out
;; Lock the account after exceeding the retry limit.
(fn [_snap _event]
{:data {:error :locked}})
:clear-error
;; Reset the error before re-submitting.
(fn [_snap _event]
{:data {:error nil}})
:clear-and-record-success
;; On successful auth, clear any residual error state.
(fn [_snap _event]
{:data {:error nil}})}
:states
{:idle
{:on
{:submit
{:target :submitting
:action :begin-submit}}} ;; resolves to :actions :begin-submit
:submitting
{:on
{:succeeded
{:target :authed
:action :clear-and-record-success}
:failed
;; multiple candidates with guards — first match wins
[{:target :error-shown
:guard :under-retry-limit ;; resolves to :guards :under-retry-limit
:action :record-failure}
{:target :locked-out
:action :lock-out}]}}
:error-shown
{:on
{:dismiss {:target :idle}
:submit {:target :submitting
:action :clear-error}}}
:authed {:meta {:terminal? true}}
:locked-out {:meta {:terminal? true}}}}))
What "named in :guards / :actions by default" buys:
- A reviewer scanning the transition table sees
:guard :under-retry-limitand immediately knows what gates the transition. - An AI proposing a change to "the retry-limit guard" can resolve the id against the machine's
:guardsmap (visible in(machine-meta :auth.login/flow)). - A diagram exporter can label the transition arrow with the guard's name.
- A Level-1 test can stub the spec's
:actions :begin-submitfor deterministic HTTP behaviour by re-defining one entry in the spec — no need to re-register a global handler. - A conformance fixture can assert "the
:failedevent in:submittingruns the:record-failureaction." create-machine-handlervalidates every keyword reference against:guards/:actionsat registration time — typos surface immediately as:rf.error/machine-unresolved-guard/:rf.error/machine-unresolved-action, not at runtime when the transition fires.
Template — internal vs external self-transitions:
{:editing
{:on
{:drag-slider
;; internal self-transition — no :target, so :exit and :entry do NOT fire.
;; Update :data via an action named in the machine's :actions map.
{:action :update-preview-radius}
:poke
;; external self-transition — :target :same-state, so :exit and :entry DO fire.
{:target :same-state
:action :randomise-poke-count}}}}
Template — :raise for transition chaining (atomic, pre-commit):
;; ... inside the machine spec:
:actions
{:notify-and-audit
(fn [_ _]
{:fx [[:raise [:notify-listeners]] ;; same machine, atomic, pre-commit
[:dispatch [:audit/login-ok]]]})} ;; runtime queue, post-commit
:states
{:submitting
{:on {:succeeded
{:target :idle
:action :notify-and-audit}}}}
:raise is a reserved fx-id inside :fx — the machine handler routes it locally. [:raise <ev>] ≡ "back into THIS machine, processed before the snapshot is committed"; [:dispatch <ev>] is the standard runtime-queue dispatch. They have different ordering semantics — see 005 §Drain semantics gotchas. The :rf.machine/spawn and :rf.machine/destroy fx-ids are registered globally as the canonical actor-lifecycle surface.
Template — :rf.machine/spawn for dynamic actors:
;; ... inside the machine spec:
:actions
{:spawn-fetch
(fn [_ [_ url]]
{:fx [[:rf.machine/spawn {:machine-id :request/protocol
:id-prefix :request/protocol
:data {:url url}
:on-spawn (fn [data id] (assoc data :pending-request id))
:start [:begin]}]]})}
:states
{:idle {:on {:fetch {:target :loading :action :spawn-fetch}}}}
After this action, (:pending-request data) is the new actor's id; subsequent transitions can dispatch to it. The spawned actor's snapshot lives at [:rf/machines <gensym'd-id>] (runtime-managed; the spawn-spec does not pick a location). The :on-spawn callback is an inline fn here — that's appropriate; it's a single non-branching assoc.
Deeper guidance — see the appendix. When you need the inline-fn vs named-action escape-hatch test, the v1 grammar subset, the parallel-regions decision between :type :parallel and the N-machines substitute, or the history-state substitute, consult CP-5 Machine Guide. It's a sibling appendix to keep CP-5 itself a build-facing prompt rather than a second machine spec.
Pattern-level discipline:
- The transition table is pure data (per 005) — serialisable, AI-readable, visualisable.
- Default to registered guards and actions. Inline fns are an escape hatch for trivial logic, not the default form. See 005 §Inspectability bias.
- Action and guard slots are single fn or single registered id — no
:actions [a1 a2 a3]vector form, no{:and [...]}compound-guard data form. Multi-step composition is fn composition; reused composition is registered with a meaningful name. - Action signature:
(fn [snapshot event] {:data {...} :fx [...]}). Strict encapsulation: no:db, no cofx — the runtime hard-disallows:dbin the action's effect map (:rf.error/machine-action-wrote-db). machine-transitionis a pure function —(definition, snapshot, event) → [next-snapshot, effects]. JVM-runnable, headless-testable.- The actor system boundary is the frame; cross-machine messages within a frame settle via run-to-completion drain.
Headless tests — three levels:
;; Level 1 — pure transition function (fastest; FSM logic only).
(deftest auth-login-happy-path-l1
(let [snap {:state :idle :data {:attempts 0 :error nil}}
[s1 _] (rf/machine-transition auth-login-table snap [:submit {:email "..."}])]
(is (= :submitting (:state s1)))))
;; Level 2 — unregistered handler fn (handler-level wiring; still no test frame).
;; Possible because create-machine-handler is a pure factory.
;; Snapshots live at [:rf/machines <id>] in app-db (runtime-managed).
(deftest auth-login-happy-path-l2
(let [handler (rf/create-machine-handler {:initial :idle ...})]
(let [{:keys [db]} (handler {:db {:rf/machines {:auth.login/flow {:state :idle :data {}}}}}
[:auth.login/flow [:submit {:email "..."}]])]
(is (= :submitting (get-in db [:rf/machines :auth.login/flow :state]))))))
;; Level 3 — registered in a test frame (full integration; required for spawn lifecycle).
(deftest auth-login-happy-path-l3
(rf/with-frame [f (rf/make-frame {:on-create [:auth/init]})]
(rf/dispatch-sync [:auth.login/flow [:submit {:email "..."}]] {:frame f})
;; Read via the framework-registered :rf/machine sub:
(is (= :submitting (:state @(rf/subscribe [:rf/machine :auth.login/flow] {:frame f}))))))
Template — view consuming sub-machine:
The framework-registered :rf/machine sub returns the snapshot for any machine; the wrapper sub-machine is the canonical user-facing form:
(rf/reg-view login-form []
(let [{:keys [state data]} @(rf/sub-machine :auth.login/flow)]
[:form
(case state
:idle [submit-button]
:submitting [spinner]
:error-shown [:<>
[:p (str "Error: " (:error data))]
[:button {:on-click #(rf/dispatch [:auth.login/flow [:dismiss]])}
"Try again"]]
:authed [:p "Welcome!"]
:locked-out [:p "Account locked."]
nil [:p "Loading..."])])) ;; nil before initialisation
For projections, compose against :rf/machine via :<-:
AI-first checklist:
- [ ] Machine id is namespaced; registered via
reg-event-fx+create-machine-handler. - [ ] No
:pathkey in the machine spec — the runtime stores snapshots at[:rf/machines <id>]. - [ ] All states are listed in
:states; no string-based or computed state names. - [ ] Every input the machine listens to is in some state's
:onmap. - [ ] Non-trivial guards and actions are named in the machine's
:guards/:actionsmaps and referenced by keyword from the transition table, not inline. Inline fns are reserved for single non-branching expressions per 005 §Inspectability bias. - [ ] Every keyword reference under
:guard/:action(in:on,:always,:entry,:exit) is a key in the spec's:guards/:actionsmap —create-machine-handlervalidates this at registration time and raises:rf.error/machine-unresolved-{guard|action}on miss. - [ ] No
reg-machine-guard/reg-machine-actioncalls — those APIs are removed; guards and actions are machine-scoped. - [ ]
:guardand:actionare single fns (or single keyword references) — not vectors. - [ ] No
[:assign ...],[:raise ...],[:fx ...]data forms in transition slots — actions return{:data {...} :fx [...]}directly. - [ ] No compound-guard
{:and ...}/{:or ...}/{:not ...}data forms — composition is fns or named compounds in:guards. - [ ] No
:dbin action effect maps — cross-cutting writes go via:fx [[:dispatch <named-event>]]. - [ ] Cross-cutting reads come through the event payload, not from
app-db. - [ ] Cross-machine reuse of a guard/action is via a Clojure var referenced from each machine's
:guards/:actionsmap — not via a global registry. - [ ] Views read state via
@(rf/sub-machine <machine-id>)(or the explicit@(rf/subscribe [:rf/machine <machine-id>])); no manualreg-subover[:rf/machines ...]. - [ ] Transition table conforms to
:rf/transition-tableschema (per Spec-Schemas). - [ ] Level-1 headless test passes via
machine-transition(no event dispatch needed). - [ ] If the machine has terminal states, they're marked
:meta {:terminal? true}. - [ ] Trace events on
:rf.machine/transitionare visible in 10x / re-frame-pair. - [ ]
(rf/machines)includes the new id;(rf/machine-meta <id>)returns its registration metadata (which includes the spec's:guards/:actionsmaps).
CP-6. Scaffold a feature¶
When to use this prompt: the user describes a thing — "add a login form," "add a cart," "let users tag items." Most "build me X" requests land here. A feature is a registry slice — events + subs + views + schemas + optional machine — addressable by a shared id-prefix.
Pre-flight checks (mandatory):
- Choose the feature id-prefix. A short, namespaced keyword:
:auth,:cart,:tagging. Sub-areas use dotted children::cart.item,:cart.checkout. Every registration the feature ships uses this prefix. - Verify the prefix is unused. Query each kind:
(rf/handlers :event)— none should start with your prefix.(rf/handlers :sub)— likewise.(rf/handlers :view)— likewise.(rf/app-schemas)— yourapp-dbpaths must be free.- Identify the feature's
app-dbshape. Pick a single root key matching the prefix::cart,:auth, etc. All feature state lives under that key. No exceptions. - Identify external dependencies. Other features (e.g.,
:authreads:user), registered fx (e.g.,:http,:localstorage), schemas the feature consumes.
Feature shape (minimum viable):
A feature ships these artefacts as a coherent bundle:
| Artefact | Required | Convention |
|---|---|---|
Schema for the feature's app-db slice |
yes | (rf/reg-app-schema [:feature] FeatureSchema) |
:on-create-style init event |
yes | :feature/initialise — sets the slice to its initial value |
| State events (the feature's instruction set) | yes | :feature/verb-noun, :feature.subarea/verb-noun |
| Subscriptions | yes | :feature/property reading from [:feature ...] |
| Views | usually | :feature/root-view plus child views |
| Machine for stateful flows | optional | :feature.flow/machine if the feature has multi-step interactions |
| Routes | optional | If the feature has its own URL surface |
| Smoke test covering the happy path | yes | Drives feature events, asserts state and renders |
Directory / namespace convention (CLJS reference):
src/my_app/
feature/
schema.cljc ;; Malli schema definitions
events.cljs ;; reg-event-* calls
subs.cljs ;; reg-sub calls
views.cljs ;; reg-view calls
machines.cljs ;; (optional) state machines
routes.cljs ;; (optional) route bindings
public.cljs ;; (optional) re-exports of the feature's public surface
test/my_app/
feature/
happy_path_test.cljs
Template — happy-path scaffold:
;; my-app/cart/schema.cljc
(ns my-app.cart.schema)
(def CartItem
[:map
[:id :uuid]
[:sku :string]
[:qty pos-int?]
[:price :double]])
(def CartState
[:map
[:items [:vector CartItem]]
[:loading? :boolean]
[:checkout-state [:enum :idle :submitting :error]]])
;; my-app/cart/events.cljs
(ns my-app.cart.events
(:require [re-frame.core :as rf]
[my-app.cart.schema :as cs]))
(rf/reg-event-db :cart/initialise
{:doc "Seed the cart slice."}
(fn [db _]
(assoc db :cart {:items [] :loading? false :checkout-state :idle})))
(rf/reg-event-db :cart.item/add
{:doc "Add an item to the cart."
:spec [:cat [:= :cart.item/add] cs/CartItem]}
(fn [db [_ item]]
(update-in db [:cart :items] conj item)))
;; my-app/cart/subs.cljs
(rf/reg-sub :cart/items
{:doc "All items in the cart."}
(fn [db _] (get-in db [:cart :items])))
(rf/reg-sub :cart/total
{:doc "Sum of qty × price across all items."}
:<- [:cart/items]
(fn [items _]
(reduce + (map #(* (:qty %) (:price %)) items))))
;; my-app/cart/views.cljs
(rf/reg-view summary [] …) ;; via CP-4 (registers :my-app.cart.views/summary)
(rf/reg-view item-list [] …)
;; on app boot
(rf/reg-app-schema [:cart] cs/CartState)
(rf/dispatch [:cart/initialise])
Smoke test — feature happy path:
(deftest cart-feature-happy-path
(rf/with-frame [f (rf/make-frame {:on-create [:cart/initialise]})]
(let [item {:id (random-uuid) :sku "ABC-1" :qty 2 :price 9.99}]
(rf/dispatch-sync [:cart.item/add item] {:frame f})
(is (= [item] (rf/compute-sub [:cart/items] (rf/get-frame-db f))))
(is (== 19.98 (rf/compute-sub [:cart/total] (rf/get-frame-db f)))))))
AI-first checklist for a feature:
- [ ] All registrations use the chosen prefix; nothing else uses it.
- [ ]
app-dbslice has a registered schema; init event produces a schema-valid value. - [ ] Every event has
:doc; structurally-shaped events have:spec. - [ ] Every sub has
:docand reads from the feature's slice ([:feature ...]). - [ ] No view dispatches an unregistered event or reads an unregistered sub.
- [ ] Happy-path smoke test runs headlessly (JVM-runnable).
- [ ] Feature ships its public surface explicitly (in
public.cljsor via doc) — which events the rest of the app may dispatch, which subs it may read. - [ ] Feature does NOT reach into another feature's
app-dbslice directly; it goes through the other feature's subs and dispatches the other feature's events.
Why feature-modularity matters for AI use:
When the user says "delete the cart feature," an AI scaffolding correctly is one git rm -r src/my_app/cart/ test/my_app/cart/ plus removing the (require ...) lines that pull the feature in — no other code references :cart/... anywhere. The id-prefix discipline turns features into truly excisable units.
When the user says "duplicate this feature for wishlists," the AI runs the same prompt with :wishlist as the prefix and reuses the same shape.
CP-7. Scaffold a route¶
When to use this prompt: the user wants the URL to reflect application state and vice versa — deep-linkable pages, browser back/forward, shareable links.
Pre-flight checks:
- Choose route ids. Convention:
:route/page-name. Examples::route/home,:route/cart,:route/cart.item-detail. - Identify the URL pattern for each. Use the canonical grammar — 012 §Path-pattern grammar is the single source of truth (literal segments,
:namepath params,{...}?optional groups,*namesplats). Do not restate the grammar in handler comments or auxiliary docs; cross-reference 012. - Distinguish path params from query params.
- Path: captured by
:name/*namesegments — declared in:paramsschema. - Query: parsed from
?key=value&...— declared in:queryschema, with:query-defaultsand:query-retainfor ergonomics. - Identify per-route data dependencies. Use
:on-match(vector of events the runtime dispatches when this route becomes active, server- and client-side). - Verify the route ids are unused.
(rf/handlers :route)enumerates registered routes.
Routing is state plus events. The URL is a derivable view of app-db; navigation is an event. The runtime ships :rf.route/navigate, :rf.route/handle-url-change, :rf/url-changed, :rf/url-requested as standard events; user code typically only calls :rf.route/navigate.
Template — register routes (declarative; the runtime owns dispatch):
(rf/reg-route :route/home
{:doc "Landing page."
:path "/"})
(rf/reg-route :route/cart
{:doc "The cart."
:path "/cart"
:on-match [[:cart/load-items] ;; runtime dispatches on match (server + client)
[:user/load-prefs]]
:on-error [:route/cart-load-failed] ;; if any :on-match event errors
:scroll :top}) ;; scroll-to-top on entering this route
(rf/reg-route :route/cart.item-detail
{:doc "Detail page for a single cart item."
:path "/cart/items/:id"
:params [:map [:id :uuid]]
:parent :route/cart}) ;; nested-layout convention
(rf/reg-route :route/search
{:doc "Search results."
:path "/search"
:query [:map [:q :string] [:page {:optional true} :int]]
:query-defaults {:page 1}
:query-retain #{:theme :locale} ;; carry through subsequent navigations
:on-match [[:search/run]]})
(rf/reg-route :rf.route/not-found
{:doc "Default 404."
:path "/404"})
No need to register :rf.route/navigate or :rf.route/handle-url-change yourself — the runtime ships them. Re-register only to override behaviour (e.g. add a guard interceptor; see 012 §Redirects and guards).
Template — :on-match data-loading event:
(rf/reg-event-fx :cart/load-items
{:doc "Load cart items for the active cart route."}
(fn handler-cart-load-items [{:keys [db]} _]
(let [user-id (get-in db [:auth :user :id])]
{:fx [[:http {:method :get
:url (str "/api/users/" user-id "/cart")
:on-success [:cart/items-loaded]
:on-error [:cart/load-failed]}]]})))
The handler reads (:rf/route db) for any path/query params it needs — the :rf/route slice is already populated when :on-match events fire.
Template — route-aware root view:
(rf/reg-view root-view []
(let [route-id @(subscribe [:rf.route/id])
transition @(subscribe [:rf.route/transition])]
[:div
(when (= transition :loading) [progress-bar])
(case route-id
:route/home [home-page]
:route/cart [cart-page]
:route/cart.item-detail [cart-item-detail-page]
:route/search [search-page]
:rf.route/not-found [not-found-page]
[not-found-page])]))
Template — links (use the registered route-link view):
[rf/route-link {:to :route/cart} "Cart"]
[rf/route-link {:to :route/cart.item-detail :params {:id item-id}} "View"]
[rf/route-link {:to :route/search :query {:q "clojure" :page 2}} "Search"]
route-link dispatches :rf/url-requested on click; the runtime's default handler classifies internal vs external and dispatches :rf.route/navigate for matched routes.
Template — wiring (called once at app boot):
(defn install-router! [frame-id]
(.addEventListener js/window "popstate"
#(rf/dispatch [:rf/url-changed (.. js/window -location -href)] {:frame frame-id}))
(rf/dispatch [:rf/url-changed (.. js/window -location -href)] {:frame frame-id}))
:rf/url-changed is the runtime's URL-change event; its default handler is :rf.route/handle-url-change.
Pattern-level discipline:
- The route is in
app-db; the URL is derivable. Never make routing state live outsideapp-db. - Navigation is an event. Don't call browser APIs directly from view code; dispatch
:rf.route/navigate(or useroute-link). - Per-route data loading is declarative — list events in
:on-matchonreg-route. The runtime dispatches them. - Server-side renders set the route via
:rf/url-changedagainst the request URL; the same:on-matchevents run server-side. - Path params and query params are separate maps —
(:params (:rf/route db))and(:query (:rf/route db)).
AI-first checklist:
- [ ] Route ids are namespaced (
:route/...). - [ ] Each route's
:pathconforms to the canonical path-pattern grammar. - [ ] Path params are declared in
:params(schema); query params are declared in:query(schema). - [ ] Per-route data dependencies are declared in
:on-match(vector of event vectors). - [ ] Per-route error handling is declared in
:on-error(single event vector) where needed. - [ ] All navigation goes through
:rf.route/navigate(orroute-link); no inlinepushState. - [ ] The root view dispatches on
:rf.route/id; per-page views are registered separately. - [ ] A
:rf.route/not-foundroute is registered. - [ ] Nested layouts use
:parent(or id-prefix-only if no shared loader/chrome is needed); read the chain via:rf.route/chain.
CP-8. Scaffold a schema¶
When to use this prompt: the user wants to describe the shape of data — an event's args, a sub's return value, a slice of app-db, a registered fx's args, a request/response payload from an external service.
Pre-flight checks:
- Identify what shape you're describing. Event vector? Sub return?
app-dbslice? Fx args? - Decide open vs closed. Default: open (consumers tolerate unknown keys). Use
:closed trueonly at system boundaries — incoming HTTP request payloads, outgoing API requests, EDN/JSON crossing process boundaries. - Check existing schemas at the same path.
(app-schema-at [:feature])— don't shadow.
Templates by site:
;; Event-vector schema (attached via :spec on reg-event-*)
(def CartItemRemoveEvent
[:tuple [:= :cart.item/remove] :uuid]) ;; [event-id item-id]
;; Registered to the event:
(rf/reg-event-db :cart.item/remove
{:spec CartItemRemoveEvent}
(fn [db [_ id]] ...))
;; Sub-return schema (attached via :spec on reg-sub)
(def CartTotal :double)
(rf/reg-sub :cart/total
{:spec CartTotal}
...)
;; app-db slice schema (registered separately via reg-app-schema)
(def CartState
[:map
[:items [:vector CartItem]]
[:loading? :boolean]
[:checkout-state [:enum :idle :submitting :error]]])
(rf/reg-app-schema [:cart] CartState)
;; Whole-app-db root schema (path = [])
(rf/reg-app-schema [] AppDbRoot)
;; Fx args schema
(rf/reg-fx :http
{:spec [:map [:method :keyword] [:url :string] [:body {:optional true} :any]]}
...)
;; Closed schema for an external boundary
(def IncomingWebhookPayload
[:map {:closed true}
[:event_type [:enum "user.created" "user.updated" "user.deleted"]]
[:user_id :int]
[:timestamp :int]])
(rf/reg-event-fx :webhook/handle
{:spec [:cat [:= :webhook/handle] IncomingWebhookPayload]}
[rf/validate-at-boundary] ;; positional; rejects payload at boundary if invalid
...)
Pattern-level discipline (per 010 and 000-Vision.md):
- Open by default. Don't add
:closed trueunless the data crosses a process boundary. - Don't model object hierarchies. A schema describes the shape of an open map. There are no classes.
- Schemas grow additively. Once a schema ships, you can add new optional keys; you cannot remove or rename existing keys without bumping a version (Spec-ulation).
- Validation runs in dev, elides in prod by default. Use
:spec/validate-at-boundaryinterceptor for runtime validation in prod at system boundaries.
AI-first checklist:
- [ ] Schema describes shape, not types of objects.
- [ ] Open by default (no
:closed trueunless at a boundary). - [ ] Path-based for
app-dbslices. - [ ] Attached as
:specon the relevantreg-*if it describes a registration's input/output. - [ ] Schema is named —
(def CartState ...)— not inline. - [ ] Conforms to 010 registration shape.
CP-9. Scaffold an SSR setup¶
When to use this prompt: the user wants the app to render server-side — for SEO, fast first paint, social-media link previews, or simply "no client JS until interaction."
Pre-flight checks:
- Identify the per-request setup events. What does the server need to dispatch before rendering? Typically:
:auth/load-session,:rf.route/handle-url-change, feature-specific:feature/load-initial-data. - Identify the fx that need server platforms. HTTP for sure. Anything else? Confirm with
(rf/handlers :fx)and audit each fx's:platformsmetadata. - Identify the views that may render under SSR. All of them, in principle. Confirm none use Form-2 outer-fn-side-effects (they don't run on the server).
- Confirm the root view is registered via
reg-view, not a plain Reagent fn.
Server-side template:
(defn handle-request [request]
(let [frame-id (gensym :ssr-frame)]
(rf/with-frame [f (rf/make-frame
{:id frame-id
:on-create [:rf/server-init request]})]
;; rebound to f
(let [final-db (rf/get-frame-db f)
hiccup ((rf/view :app/root)) ;; the registered root view
html (rf/render-to-string hiccup {:frame f})
payload {:rf/version "1.0"
:rf/frame-id frame-id
:rf/app-db final-db
:rf/render-hash (hash hiccup)}]
{:status 200
:headers {"Content-Type" "text/html"}
:body (page-template html (pr-str payload))}))))
(defn page-template [body-html serialised-payload]
(str "<!DOCTYPE html>"
"<html><head>...</head>"
"<body>"
"<div id='app'>" body-html "</div>"
"<script id='__rf_payload'>" serialised-payload "</script>"
"<script src='/main.js'></script>"
"</body></html>"))
Server-side :rf/server-init handler:
(rf/reg-event-fx :rf/server-init
{:doc "Server-side per-request init. Runs setup events from the request context."
:platforms #{:server}}
(fn handler-rf-server-init [{:keys [db]} [_ request]]
{:db (assoc db
:session (:session request)
:route (parse-url (:uri request) routes))
:fx [[:http {:method :get :url "/api/auth/me" :on-success [:auth/me-loaded]}]
;; ... feature-specific data fetches
]}))
The drain settles before with-frame returns; the final state is captured.
Client-side bootstrap:
(defonce client-frame
(rf/reg-frame :app/main {:on-create [:client/bootstrap]}))
(defn read-server-payload []
(-> (.getElementById js/document "__rf_payload")
.-textContent
cljs.reader/read-string))
(defn boot! []
(let [payload (read-server-payload)]
(when payload
(rf/dispatch-sync [:rf/hydrate payload] {:frame :app/main}))
(rdc/render (.getElementById js/document "app")
[(rf/view :app/root)])))
(boot!)
Pattern-level discipline (per 011):
- All server-side fx have
:platformscontaining:server. Anything else is skipped (with a:rf.fx/skipped-on-platformtrace event). - The root view is registered (
reg-view), pure, returns a serialisable render-tree. - The render-tree → string emitter is JVM-runnable; no React on the server.
- The hydration payload is open-map-shaped and conforms to
:rf/hydration-payload. - Mismatch detection runs on first client render; the runtime emits
:rf.ssr/hydration-mismatchtraces on divergence.
AI-first checklist:
- [ ] Per-request frame is created and destroyed within
with-frame. - [ ] All setup events have
:platformsset or are universal (no:platformskey, runs everywhere). - [ ] Render-tree → string is pure; no React, no DOM, no JS APIs on the server.
- [ ] Hydration payload includes
:rf/version,:rf/frame-id,:rf/app-db, optional:rf/render-hash. - [ ] Client
[:rf/hydrate ...]event seeds before first render. - [ ] First-client-render hash matches server hash (test in dev with mismatch detection on).
- [ ] Page template injects the payload as a
<script>element withid="__rf_payload". - [ ] All client-only effects (DOM mutation, localStorage) are tagged
:platforms #{:client}.
Cross-references¶
- Principles.md §Construction prompts as a deliverable — why this artefact exists.
- 000-Vision.md — the goals and contract.
- MIGRATION.md — the sibling artefact for upgrades.
- API.md — signatures the prompts produce calls against.
Worked examples (each prompt, in action)¶
The 7GUIs example series and the login example demonstrate every prompt in working code (each example sits alongside its own *.spec.cjs Playwright smoke test):
| Prompt | Example |
|---|---|
| CP-1 (event handler) | All examples; especially the bookkeeping events in Flight Booker and the undo events in Circle Drawer |
| CP-2 (subscription) | Temperature Converter shows :<- chains; Flight Booker shows multi-input chains for derived enabled-state |
| CP-3 (registered fx) | Login shows :platforms metadata + a stub fx for tests; Timer shows :dispatch-later; Flight Booker shows a custom :notify fx |
| CP-4 (registered view) | All examples use Var-reference Form-1 (canonical) |
| CP-5 (state machine) | Login — full transition table with guards, actions, terminal states |
| CP-6 (feature scaffold) | Login is a full feature: schema + events + subs + views + machine + tests |
| CP-7 (route) | Routing example — three-page app (home / articles / article-detail / 404), :rf.route/navigate, :rf.route/handle-url-change, route-link, server-and-client-shared handler |
| CP-8 (schema) | All examples register app-db slice schemas; Login and Flight Booker also attach event schemas |
| CP-9 (SSR setup) | SSR example — single .cljc file demonstrating both server (handle-request returning HTML+payload) and client (:rf/hydrate seeding) flows; JVM-runnable smoke test |