Skip to content

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) scaffolds the Story authoring surface — the re-frame.story tools artefact (tools/story/) has shipped, so its full template lives in the catalogue below; the design sketch lives in 007-Stories.md.

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 :schema metadata 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.

  1. Choose a namespaced id. Lowercase, kebab-case. The id-prefix matches the feature (per Conventions §Feature-modularity prefix convention).
  2. Verify the id is unused. Query the registry via the public registrar query API for the relevant kind (e.g., (rf/registrations :event), (rf/registrations :sub), (rf/registrations :fx), (rf/registrations :view), (rf/registrations :route)).
  3. 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. There is one event-registration form, reg-event: its handler takes coeffects and the event vector and returns a closed effects map. If the reaction modifies state only, return {:db ...}. If it also produces side-effects (HTTP call, navigation, dispatch chain, local-storage write), add :fx alongside (or instead of) :db.

Pre-flight delta (in addition to the shared preamble above):

  • Id-shape convention: :feature/verb-noun or :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-db path, your handler must produce schema-compliant output (validation runs in dev).

Template — reg-event (state-only):

(rf/reg-event :feature/verb-noun
  {:doc    "One-sentence what-and-why."
   :schema EventSchema}                       ;; optional Malli schema for the event vector
  (fn handler-feature-verb-noun [{:keys [db]} [_ {:keys [field-1 field-2]}]]
    ;; Pure: read db (and any injected cofx) from coeffects, return an effect map.
    ;; Multi-arg events take a single map payload; destructure named keys.
    ;; No side-effects. State change is the explicit :db effect — no db-only return shape.
    {:db (assoc-in db [:feature :path] field-1)}))

Template — reg-event (with effects):

(rf/reg-event :feature/verb-noun
  {:doc    "One-sentence what-and-why."
   :schema EventSchema
   ;; Add :interceptors [...] here when this handler needs an event chain.
   }
  (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. Add :fx for side-effects.
    {: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/removehandler-cart-item-remove. The name shows up in stack traces and tooling; anonymous lambdas hide.

Smoke test:

(deftest feature-verb-noun-test
  (rf/with-new-frame [f (rf/make-frame {})]
    (rf/dispatch-sync [:feature/initialise] {:frame f})   ;; seed via a setup dispatch
    (rf/dispatch-sync [:feature/verb-noun "value"] {:frame f})
    (is (= "value" (get-in (rf/app-db-value f) [:feature :path])))))

AI-first checklist before declaring done:

  • Id is namespaced and unused.
  • Handler is pure (no side-effects in the body; all effects — including the :db write — are returned in the effect map).
  • :doc is present and one sentence.
  • Shape is described by the host's idiom: :schema (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-db path (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 :cart.item/remove
  {:doc    "Remove an item from the cart by id."
   :schema CartItemRemoveEvent}
  (fn handler-cart-item-remove [{:keys [db]} [_ item-id]]
    {:db (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/property or :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-db directly (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-db directly. 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 read app-db directly.
  • :doc is 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-url, :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
   :schema    [: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 fx-handler ctx — a small map with `:frame` (the frame ID, a
    ;;    keyword, NOT the live record), `:event` (origin event vector), and a
    ;;    runtime-internal `:envelope`. See Spec-Schemas §:rf/handler-context.
    ;; 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 (fx-ctx, args) → side-effect, where fx-ctx is the small fx-handler map (:frame = frame id, :event, runtime-internal :envelopeSpec-Schemas §:rf/handler-context), not the event handler's coeffects map. 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 drain resumes.
  • The handler may not modify app-db directly — 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-platform traces 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 — :fx-overrides and :initial-events are record-config keys, so they ride
;; the advanced record-config `re-frame.frame/make-frame`, not the EP-0023 object
;; constructor `rf/make-frame` (which takes :images and fails loud on a record-only key)
(rf/with-new-frame [f (re-frame.frame/make-frame {:fx-overrides   {:http :http.canned-200}
                                                  :initial-events [[: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.
  • :platforms is set explicitly (don't rely on the default).
  • :schema describes the args shape.
  • :doc is 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}
   :schema    [: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-name or :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/registrations :sub)) or scaffolded as a sibling — if missing, invoke CP-2.
  • Identify the events the view will dispatch. Each must be already registered ((rf/registrations :event)) — if missing, invoke CP-1.

Template — Form-1 (simple render fn):

(rf/reg-view ^{:doc    "One-sentence what-and-why."
               :schema [: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 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-new-frame [f (rf/make-frame {})]
    (rf/dispatch-sync [:feature/initialise] {:frame f})   ;; seed via a setup dispatch
    (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-change lambdas dispatch named events (no inline swap!/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 whose body comes from make-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 make-machine-handler. This is the default because the named id is a name that is reusable, addressable, and clearer for humans, tools, and AIs — visualisers label arrows with the id, AIs and conformance fixtures resolve the id against the machine's :guards / :actions map, and tests stub by id. (The rationale is not source visibility: an inline fn's :source-code text is co-located on its enclosing node in dev, per 005 §Source-coord stamping, so an inline body is inspectable — it just has no public name to address.) 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:

  1. Choose a machine id. Convention: :feature.flow/machine or :feature/flow. Examples: :auth.login/flow, :checkout/flow, :video-player/flow.
  2. Verify the id is unused. (rf/registrations :event) — the machine reuses the :event registry kind. (No matching :sub registration 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.
  3. List the states. Discrete, named (:idle, :submitting, :authed, :error-shown).
  4. List the inputs (sub-events) that move between states. Each input triggers exactly one transition.
  5. Identify guards and actions; default to naming them in :guards / :actions. Each guard (fn [{:keys [data event]}] boolean) and each action (fn [{:keys [data event]}] {:data {...} :fx [...]}) is a key in the machine's :guards / :actions map (referenced from transitions by keyword). Per every machine callback receives a single context-map argument with :data, :event, :state, :meta. 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.runtime/machines :snapshots <machine-id>] in the frame's runtime-db partition (not app-db). For id :auth.login/flow, the snapshot is at [:rf.runtime/machines :snapshots :auth.login/flow] and contains {:state ... :data ...}. You do not pick the path — make-machine-handler does not accept a :path key. Per-frame isolation is automatic: each frame has its own runtime-db and thus its own [:rf.runtime/machines :snapshots] map. See 005 §Where snapshots live.

Reading the snapshot in views. The framework ships :rf/machine as a standard parametric sub. @(rf/subscribe [:rf/machine :auth.login/flow]) returns the snapshot — no per-machine reg-sub needed. Destructure inline, or write a derived sub :<- [:rf/machine <id>] for projections. See 005 §Subscribing to machines via the :rf/machine sub.

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'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 :auth.login/flow
  {:doc "Login flow: idle → submitting → authed / error-shown / locked-out."}
  (rf/make-machine-handler
    {:initial :idle
     :data    {:attempts 0 :error nil}

     :guards
     {:under-retry-limit
      ;; Has this login had fewer than 3 prior attempts?
      (fn [{:keys [data]}]
        (< (:attempts data) 3))}

     :actions
     {:begin-submit
      ;; Clear the prior error and emit the HTTP request for credential check.
      ;; Destructure the credentials out of the :event vector.
      (fn [{[_ creds] :event}]
        {: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 [{:keys [data]}]
        {:data {:attempts (inc (:attempts data))
                :error    :credentials}})

      :lock-out
      ;; Lock the account after exceeding the retry limit.
      (fn [_ctx]
        {:data {:error :locked}})

      :clear-error
      ;; Reset the error before re-submitting.
      (fn [_ctx]
        {:data {:error nil}})

      :clear-and-record-success
      ;; On successful auth, clear any residual error state.
      (fn [_ctx]
        {: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-limit and immediately knows what gates the transition.
  • An AI proposing a change to "the retry-limit guard" can resolve the id against the machine's :guards map (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-submit for 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 :failed event in :submitting runs the :record-failure action."
  • make-machine-handler validates every keyword reference against :guards / :actions at 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 WITH :reenter? true, so
   ;; :exit and :entry DO fire (and a compound re-descends its :initial). The
   ;; :reenter? opt-in is required: a self / ancestor target is INTERNAL by
   ;; default (XState-v5).
   {:target :same-state
    :reenter? true
    :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] :event}]
   {:fx [[:rf.machine/spawn {:machine-id :request/protocol
                             :id-prefix  :request/protocol
                             :data       {:url url}
                             :on-spawn   (fn [{:keys [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.runtime/machines :snapshots <gensym'd-id>] in runtime-db (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 N-machines-per-region, or first-class history states (:type :history), 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 [{:keys [data event state meta]}] {:data {...} :fx [...]}) — single context-map argument. Strict encapsulation: no :db, no cofx — the runtime hard-disallows :db in the action's effect map (:rf.error/machine-action-wrote-db).
  • machine-transition is 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 make-machine-handler is a pure factory.
;; Snapshots live at [:rf.runtime/machines :snapshots <id>] in runtime-db (runtime-managed):
;; the handler reads the snapshot from the :rf.db/runtime cofx and writes it back
;; as a :rf.db/runtime effect — NOT app-db's :db.
(deftest auth-login-happy-path-l2
  (let [handler (rf/make-machine-handler {:initial :idle ...})
        cofx    {:rf.db/runtime {:rf.runtime/machines {:snapshots {:auth.login/flow {:state :idle :data {}}}}}}
        effects (handler cofx [:auth.login/flow [:submit {:email "..."}]])
        runtime (:rf.db/runtime effects)]
    (is (= :submitting (get-in runtime [:rf.runtime/machines :snapshots :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-new-frame [f (rf/make-frame {})]
    (rf/dispatch-sync [:auth/init] {:frame f})   ;; seed via a setup dispatch
    (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 [:rf/machine <id>]:

The framework-registered :rf/machine sub returns the snapshot for any machine; the canonical user-facing form is the ordinary [:rf/machine <id>] subscription vector:

(rf/reg-view login-form []
  (let [{:keys [state data]} @(rf/subscribe [:rf/machine :auth.login/flow])]
    [:form
     (case state
       :idle        [submit-button]
       :submitting  [spinner]
       :error-shown [:<>
                     [:p (str "Error: " (:error data))]
                     [:button {:on-click #(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 :<-:

(rf/reg-sub :auth.login/state
  :<- [:rf/machine :auth.login/flow]
  (fn [snap _] (:state snap)))

AI-first checklist:

  • Machine id is namespaced; registered via reg-event + make-machine-handler.
  • No :path key in the machine spec — the runtime stores snapshots at [:rf.runtime/machines :snapshots <id>] in runtime-db.
  • All states are listed in :states; no string-based or computed state names.
  • Every input the machine listens to is in some state's :on map.
  • Non-trivial guards and actions are named in the machine's :guards / :actions maps 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 / :actions map — make-machine-handler validates this at registration time and raises :rf.error/machine-unresolved-{guard|action} on miss.
  • No reg-machine-guard / reg-machine-action calls — those APIs are removed; guards and actions are machine-scoped.
  • :guard and :action are 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 :db in 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 / :actions map — not via a global registry.
  • Views read state via @(rf/subscribe [:rf/machine <machine-id>]); no manual reg-sub over [:rf.runtime/machines :snapshots ...].
  • Transition table conforms to :rf/transition-table schema (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/transition are 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 / :actions maps).

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):

  1. 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.
  2. Verify the prefix is unused. Query each kind:
  3. (rf/registrations :event) — none should start with your prefix.
  4. (rf/registrations :sub) — likewise.
  5. (rf/registrations :view) — likewise.
  6. (rf/app-schemas) — your app-db paths must be free.
  7. Identify the feature's app-db shape. Pick a single root key matching the prefix: :cart, :auth, etc. All feature state lives under that key. No exceptions.
  8. Identify external dependencies. Other features (e.g., :auth reads :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) — a dev-time assertion and an introspection surface, not a production guard (see below)
:initial-events 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

What the schema row buys — and what it does not. reg-app-schema is required because the registration is genuinely wanted: it documents the slice's shape, it catches your own mistakes the moment you make them, and it is what tools and agents read back through (rf/app-schemas). But registering a schema and checking one are two different acts. A production build performs the registration and elides the check (010 §Production builds), so a candidate that violates FeatureSchema installs silently there — no rejection, no rollback, no trace. Write any rule that must hold in production as a branch in the handler, and put untrusted input behind the :rf.schema/at-boundary interceptor, which is the one validation seam that survives the gate.

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 :cart/initialise
  {:doc "Seed the cart slice."}
  (fn [{:keys [db]} _]
    {:db (assoc db :cart {:items [] :loading? false :checkout-state :idle})}))

(rf/reg-event :cart.item/add
  {:doc    "Add an item to the cart."
   :schema [:cat [:= :cart.item/add] cs/CartItem]}
  (fn [{:keys [db]} [_ item]]
    {:db (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-new-frame [f (rf/make-frame {})]
    (rf/dispatch-sync [:cart/initialise] {:frame f})   ;; seed via a setup dispatch
    (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/app-db-value f))))
      (is (== 19.98 (rf/compute-sub [:cart/total] (rf/app-db-value f)))))))

AI-first checklist for a feature:

  • All registrations use the chosen prefix; nothing else uses it.
  • app-db slice has a registered schema; init event produces a schema-valid value.
  • Every event has :doc; structurally-shaped events have :schema.
  • Every sub has :doc and 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.cljs or 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-db slice 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:

  1. Choose route ids. Convention: :route/page-name. Examples: :route/home, :route/cart, :route/cart.item-detail.
  2. Identify the URL pattern for each. Use the canonical grammar — 012 §Path-pattern grammar is the single source of truth (literal segments, :name path params, {...}? optional groups, *name splats). Do not restate the grammar in handler comments or auxiliary docs; cross-reference 012.
  3. Distinguish path params from query params.
  4. Path: captured by :name / *name segments — declared in :params schema.
  5. Query: parsed from ?key=value&... — declared in the :query schema, with :query-defaults for absent keys. Carrying query state ACROSS routes is an application-level pure fold over the destination address, not route metadata.
  6. Identify per-route data dependencies. Use :on-match (vector of events the runtime dispatches when this route becomes active, server- and client-side).
  7. Verify the route ids are unused. (rf/registrations :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.route/transitioned, :rf.route/url-requested as standard events; user code typically only calls :rf.route/navigate.

Template — register routes (declarative; the runtime owns dispatch):

The canonical 3-slot grammar puts the URL :path pattern in the third VALUE slot:

(rf/reg-route :route/home
  {:doc "Landing page."}
  "/")

(rf/reg-route :route/cart
  {:doc      "The 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
  "/cart")

(rf/reg-route :route/cart.item-detail
  {:doc    "Detail page for a single cart item."
   :params [:map [:id :uuid]]
   :parent :route/cart}                                   ;; nested-layout convention
  "/cart/items/:id")

(rf/reg-route :route/search
  {:doc            "Search results."
   :query          [:map [:q :string] [:page {:optional true} :int]]
   :query-defaults {:page 1}
   :on-match       [[:search/run]]}
  "/search")

(rf/reg-route :rf.route/not-found
  {:doc "Default 404."}
  "/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 :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 the route slice — which lives in runtime-db at [:rf.runtime/routing :current], NOT app-db — for any path/query params it needs, via (get-in (:rf.db/runtime (rf/frame-state-value frame-id)) [:rf.runtime/routing :current]) (the consumer-facing sub-id is :rf/route). The 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.route/url-requested on click; the runtime's default handler classifies internal vs external and dispatches :rf.route/navigate for matched routes.

Template — wiring (declare URL ownership on the frame; there is no install step):

A frame owns the browser URL by carrying :url-bound? true, and that declaration is the wiring: the frame lifecycle installs the browser URL-change listener on creation — performing the initial URL → route-slice sync in the same step — and removes it on destroy. :url-strategy picks the address-bar shape (omit it for the default HTML5-history, path-form strategy; rf.routing/hash-url-strategy for a no-server-rewrite static host, optionally wrapped in rf.routing/with-base-path for a sub-path deployment).

(rdc/render @react-root
  [rf/frame-root {:id             :app/main
                  :doc            "The application frame."
                  :url-bound?     true                       ;; this frame owns the browser URL
                  :url-strategy   rf.routing/history-url-strategy   ;; the default; omittable
                  :initial-events [[:app/initialise]]}
   [root-view]])

Nothing else is needed, and nothing else is permitted: an app never adds a popstate listener, never dispatches an initial :rf.route/handle-url-change, and never calls an install / remove pair — those exports are retired (see 012 §popstate drives the URL-owner frame). A hand-rolled listener would duplicate the framework's, dispatch a second initial sync, and misreport the navigation cause in diagnostics.

Routing has two co-equal URL-change events. :rf.route/handle-url-change (default scroll :restore) is dispatched by the :url-bound? lifecycle's own listener — for Back/Forward and for the initial sync alike, distinguished by the cause rider it stamps — while forward navigation, a route-link click or programmatic push, dispatches :rf.route/transitioned (default scroll :top). Both run the identical slice-rewrite; neither delegates to the other.

Pattern-level discipline:

  • The route is framework-owned state in runtime-db ([:rf.runtime/routing :current], read via the :rf/route sub); the URL is derivable. Never make routing state live in a parallel router outside the frame.
  • Navigation is an event. Don't call browser APIs directly from view code; dispatch :rf.route/navigate (or use route-link).
  • URL ownership is declarative:url-bound? true on exactly one frame. Never hand-roll a popstate listener or an initial :rf.route/handle-url-change dispatch; the frame lifecycle owns both, and duplicating them double-syncs the slice.
  • Per-route data loading is declarative — list events in :on-match on reg-route. The runtime dispatches them.
  • Server-side renders set the route via :rf.route/handle-url-change against the request URL; the same :on-match events run server-side.
  • Path params and query params are separate maps in the runtime-db route slice — (get-in (:rf.db/runtime (rf/frame-state-value frame-id)) [:rf.runtime/routing :current :params]) and (get-in (:rf.db/runtime (rf/frame-state-value frame-id)) [:rf.runtime/routing :current :query]).

AI-first checklist:

  • Route ids are namespaced (:route/...).
  • Each route's :path conforms 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 (or route-link); no inline pushState.
  • Exactly one frame carries :url-bound? true; no app-space popstate listener and no initial :rf.route/handle-url-change dispatch.
  • The root view dispatches on :rf.route/id; per-page views are registered separately.
  • A :rf.route/not-found route 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:

  1. Identify what shape you're describing. Event vector? Sub return? app-db slice? Fx args?
  2. Decide open vs closed. Default: open (consumers tolerate unknown keys). Use :closed true only at system boundaries — incoming HTTP request payloads, outgoing API requests, EDN/JSON crossing process boundaries.
  3. Check existing schemas at the same path. (app-schema-at [:feature]) — don't shadow.

Templates by site:

;; Event-vector schema (attached via :schema on reg-event-*)
(def CartItemRemoveEvent
  [:tuple [:= :cart.item/remove] :uuid])      ;; [event-id item-id]

;; Registered to the event:
(rf/reg-event :cart.item/remove
  {:schema CartItemRemoveEvent}
  (fn [{:keys [db]} [_ id]] {:db ...}))

;; Sub-return schema (attached via :schema on reg-sub)
(def CartTotal :double)

(rf/reg-sub :cart/total
  {:schema 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
  {:schema [: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 :webhook/handle
  {:schema [:cat [:= :webhook/handle] IncomingWebhookPayload]
   :interceptors [:rf.schema/at-boundary]}                   ;; ref by id (EP-0022) — rejects payload at boundary if invalid
  ...)

Pattern-level discipline (per 010 and 000-Vision.md):

  • Open by default. Don't add :closed true unless 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. Reference the :rf.schema/at-boundary interceptor by id ({:interceptors [:rf.schema/at-boundary]} — the rf/validate-at-boundary-interceptor Var is the registration-boundary input, not a chain entry) for runtime validation in prod at system boundaries.

AI-first checklist:

  • Schema describes shape, not types of objects.
  • Open by default (no :closed true unless at a boundary).
  • Path-based for app-db slices.
  • Attached as :schema on the relevant reg-* 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:

  1. 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.
  2. Identify the fx that need server platforms. HTTP for sure. Anything else? Confirm with (rf/registrations :fx) and audit each fx's :platforms metadata.
  3. 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).
  4. 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-new-frame [f (rf/make-frame
                       {:id     frame-id
                        :images [app-image]})]
      ;; rebound to f. The constructor takes :images; run the per-request
      ;; setup via a dispatch (or declaratively via :initial-events).
      (rf/dispatch-sync [:rf/server-init request] {:frame f})
      (let [final-db (rf/app-db-value 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 :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/make-frame {:id :app/main :initial-events [[: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 :platforms containing :server. Anything else is skipped (with a :rf.fx/skipped-on-platform trace 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-mismatch traces on divergence.

AI-first checklist:

  • Per-request frame is created and destroyed within with-frame.
  • All setup events have :platforms set or are universal (no :platforms key, 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 with id="__rf_payload".
  • All client-only effects (DOM mutation, localStorage) are tagged :platforms #{:client}.

CP-10. Scaffold a story (story / variant / workspace)

When to use this prompt: the user wants to demonstrate a view in a named state — an isolated preview of a component, a catalogue of its states for QA or design review, a documentation page, or a headless fixture that a cljs.test deftest drives. If you can name the component and the states you want to show it in, you have a story (the topic) whose variants (the states) render in a workspace (the layout). This is re-frame2's Storybook parity surface — but a variant is pure data, not a function, so it round-trips as EDN, drives visual-regression, and doubles as a test fixture.

The Story authoring surface lives in the re-frame.story tools artefact ((:require [re-frame.story :as story])), registered on the tool-owned side-table — :story / :variant / :workspace are not framework registry kinds. Enumerate via (story/registrations :story) / :variant / :workspace. See 007-Stories.md for the design and tools/story/spec/ for the full library spec.

Pre-flight delta (in addition to the shared preamble above — the registry is Story's, not the framework registrar's):

  • Id-shape convention (library-owned prefixes, per Conventions §Library-owned prefixes):
  • Story: :story.<path> — dotted segments organise the tool's navigator tree. Examples: :story.auth.login-form, :story.cart.summary.
  • Variant: :story.<path>/<variant> — always belongs to exactly one story; the story id is everything before /. Examples: :story.auth.login-form/empty, :story.auth.login-form/validation-error.
  • Workspace: :Workspace.<Path>/<name> — a layout artefact. Examples: :Workspace.Auth/all-states, :Workspace.Auth/docs.
  • Verify the ids are unused on the Story side-table: (story/registrations :story), (story/registrations :variant), (story/registrations :workspace) (NOT (rf/registrations ...) — these kinds are library-owned).
  • Identify the component. The :component slot on reg-story is a registered :view id — verify it via (rf/registrations :view); if the view doesn't exist yet, scaffold it via CP-4 first.
  • Identify the events each variant drives. :setup / :script steps dispatch registered events — each must be registered ((rf/registrations :event)) or scaffolded via CP-1.
  • Register project tags before use. A variant :tags set may only carry registered tags; the seven canonical tags (:dev :docs :test :screenshot :experimental :internal :agent) auto-register at Story load, but project-specific tags must be reg-tag'd first or the variant raises :rf.error/unknown-tag.

Key idea: a variant is data, not a function. Every field in a variant body is plain data — vectors, maps, keywords, strings — no fn-valued slots. Where Storybook takes a decorator function, re-frame2 takes a registered id (story/reg-decorator :centered-layout {...}); the closure lives at the decorator's registration site, never at the variant call site. This is what makes a variant wire-portable, storable, diffable, and usable as a test fixture. See 007-Stories.md §Variant artefact contract.

Template — Form A (story + separate variants; hot-reload-friendly):

(ns my-app.stories.login-form
  (:require [re-frame.story :as story]))

;; The story — the topic. Declares the component + shared fixtures.
(story/reg-story :story.auth.login-form
  {:doc        "The login form component."
   :component  :app.auth/login-form            ;; a registered :view id (verify via CP-4)
   :decorators [[:centered-layout]]            ;; id-valued; the closure lives at reg-decorator
   :args       {:placeholder  "you@example.com"
                :submit-label "Sign in"}
   :argtypes   {:placeholder  {:control :text}
                :submit-label {:control :text}}
   :tags       #{:dev :docs}})

;; Each variant — one named state. Registers against the parent story;
;; inherits its decorators/args, overrides by key. :setup is pure data —
;; a vector of tagged steps settled into the variant's frame in order.
(story/reg-variant :story.auth.login-form/empty
  {:doc   "Fresh form, nothing entered."
   :setup [[:dispatch [:auth/initialise]]]})

(story/reg-variant :story.auth.login-form/validation-error
  {:doc   "Invalid email shown inline after submit."
   :setup [[:dispatch [:auth/initialise]]
           [:dispatch [:auth/email-changed "not-an-email"]]
           [:dispatch [:auth/login-pressed]]]
   :tags  #{:dev :docs :test}})               ;; also usable as a test fixture

Template — Form B (combined; one form per story, desugars to Form A):

The :variants map desugars at macro-expansion time to N independent reg-variant calls, so hot-reload-by-variant still works. Choose by ergonomics — both forms are first-class.

(story/reg-story :story.auth.login-form
  {:doc       "The login form component."
   :component :app.auth/login-form
   :args      {:placeholder "you@example.com"}
   :tags      #{:dev :docs}
   :variants  {:empty            {:setup [[:dispatch [:auth/initialise]]]}
               :validation-error {:setup [[:dispatch [:auth/initialise]]
                                          [:dispatch [:auth/email-changed "not-an-email"]]
                                          [:dispatch [:auth/login-pressed]]]
                                  :tags  #{:dev :docs :test}}}})

Template — composed variant (:extends a base by id, override by data):

(story/reg-variant :story.auth.login-form/loading-with-prefill
  {:extends :story.auth.login-form/loading    ;; parent variant id; merged at registration
   :setup   [[:dispatch [:auth/initialise]]
             [:dispatch [:auth/email-changed "alice@example.com"]]
             [:dispatch [:auth/password-changed "hunter2"]]
             [:dispatch [:auth/login-pressed]]]
   :tags    #{:dev :docs}})

:extends resolves at registration (child wins key-by-key, pure-data merge, no inheritance ceremony); it must resolve to a registered variant id, and cycles are a registration error.

Template — workspace (a layout over variants):

;; :grid — side-by-side cells (QA / review)
(story/reg-workspace :Workspace.Auth/all-states
  {:doc      "Every login-form state side by side, for QA review."
   :layout   :grid
   :variants [:story.auth.login-form/empty
              :story.auth.login-form/validation-error
              :story.auth.login-form/loading]})

;; :prose — markdown-flavoured documentation page interleaving prose + variants
(story/reg-workspace :Workspace.Auth/docs
  {:doc     "Auth flow documentation page."
   :layout  :prose
   :content [{:type :prose   :body "## The login flow\n\n..."}
             {:type :variant :id :story.auth.login-form/empty}
             {:type :prose   :body "When the email is invalid:"}
             {:type :variant :id :story.auth.login-form/validation-error}]})

Stubbing effects for a variant (per-variant, id-valued): stub an fx via a decorator reference rather than a mocking layer baked into reg-variant. The stubbed fx may be the framework-shipped :rf.http/managed or a user-supplied fx; the mechanism is the same.

(story/reg-variant :story.auth.login-form/loading
  {:doc        "Submit pressed, server response pending."
   :setup      [[:dispatch [:auth/initialise]]
                [:dispatch [:auth/email-changed "alice@example.com"]]
                [:dispatch [:auth/login-pressed]]]
   :decorators [[:force-fx-stub :rf.http/managed {:status :pending}]]})

Pattern-level discipline (per 007-Stories.md):

  • Variants are pure data. No fn-valued slots. Decorators, loaders, and arg→event maps are all id-valued references; the only fn-valued part is the registered handler an id points to.
  • The component and every dispatched event are already registered (:view / :event kinds). A story is a demonstration, not a place to define new app behaviour.
  • Ids follow the library-owned prefixes (:story.* / :Workspace.*); the hierarchy is recoverable from the id alone — no separate :title field.
  • Args resolve by precedence: global < story < variant (deep-merge). Declare shared args once on the story; override per-variant.
  • A variant tagged :test doubles as a headless fixture — drive it from a cljs.test deftest via run-variant (Story is Xray/Story CLJS-unit-first; a Playwright spec is the exception, not the default).

Smoke test (headless via run-variant, per tools/story/spec/Tutorial-CLJS-Unit.md):

run-variant runs the four-phase lifecycle (loaders → events → render → play) and returns a result map; assert on :lifecycle + :app-db + :assertions. It resolves synchronously when the variant has no :loaders; drive it under cljs.test/async when loaders are present.

(deftest login-form-validation-error-shows-inline
  (async done
    (-> (story/run-variant :story.auth.login-form/validation-error)
        (async-lib/then
          (fn [result]
            (is (= :ready (:lifecycle result))
                "lifecycle reached :ready after the four phases")
            (is (= :invalid (-> result :app-db :auth :email-status))
                "the invalid-email setup left the form in the error state")
            (is (empty? (filter #(false? (:passed? %)) (:assertions result)))
                "no in-variant :script assertions failed")
            (story/destroy-variant! :story.auth.login-form/validation-error)
            (done))))))

AI-first checklist:

  • Story / variant / workspace ids are namespaced under the library-owned prefixes (:story.* / :Workspace.*) and unused on the Story side-table.
  • :component is a registered :view id; every event a :setup / :script step dispatches is a registered :event id.
  • The variant body is 100% data — no fn-valued slots; decorators/loaders/arg-maps are id-valued references.
  • Every tag in a variant's :tags set is registered (canonical tag or reg-tag'd project tag).
  • :extends, where used, resolves to a registered variant id; no cycles.
  • Each variant has a one-sentence :doc.
  • A workspace's :layout matches its content slot (:grid/:tabs:variants; :prose:content; :variants-grid → enumerated; :custom:render).
  • A :test-tagged variant is drivable headlessly via run-variant (:lifecycle reaches :ready; asserted :app-db / :assertions).
  • Registration forms elide in production builds (the reg-* macros gate on re-frame.story.config/enabled?).

Example — full worked artefact (story + variants + workspace + a driving deftest):

(ns my-app.stories.counter
  (:require [re-frame.story :as story]))

(story/reg-story :story.counter
  {:doc       "A minimal counter widget."
   :component :app.counter/widget
   :tags      #{:dev :docs}})

(story/reg-variant :story.counter/empty
  {:doc   "Fresh counter at zero."
   :setup [[:dispatch [:counter/initialise]]]})

(story/reg-variant :story.counter/at-five
  {:doc    "Counter driven to five, then asserted in-variant."
   :setup  [[:dispatch [:counter/initialise]]]
   :script [[:dispatch [:counter/set 5]]
            [:assert [:rf.assert/path-equals [:count] 5]]]
   :tags   #{:dev :docs :test}})

(story/reg-workspace :Workspace.Counter/states
  {:doc      "Counter states side by side."
   :layout   :grid
   :variants [:story.counter/empty
              :story.counter/at-five]})

;; A deftest drives the :test-tagged variant headlessly:
(deftest counter-at-five-lands-on-five
  (let [result (story/run-variant :story.counter/at-five)]  ;; sync — no :loaders
    (is (= :ready (:lifecycle result)))
    (is (= 5 (-> result :app-db :count)))
    (story/destroy-variant! :story.counter/at-five)))

CP-11. Register an interceptor

When to use this prompt: the user wants a piece of full-context program behaviour that runs around event handlers — an auth gate, an audit log, trace-payload redaction, a coeffect injection, an effect rewrite, a focus on an app-db sub-slice. Event registration is one reg-event form, with context -> context work expressed as interceptors (EP-0018); an interceptor is load-bearing program structure — a first-class registered member, authored once with reg-interceptor and referenced from event/frame :interceptors chains by id (EP-0022). Reach for this whenever a behaviour must wrap more than one handler, or must be named, addressable, override-able, and visible in tooling. A one-off transform that belongs to a single handler stays inside that handler's body — interceptors are for cross-cutting, reusable behaviour.

Pre-flight delta (in addition to the shared preamble above):

  • Id-shape convention: a single namespaced keyword. Examples: :auth/required, :audit/record-event, :app/unwrap. The relevant registry kind is :interceptor — verify the id is unused via (rf/registrations :interceptor). Application ids are application-owned; the :rf.interceptor/* namespace is reserved for framework standard refs (per Conventions §Reserved namespaces).
  • Decide static vs parameterized. A static interceptor has fixed behaviour ({:before} / {:after} / {:before :after}). A parameterized interceptor ({:factory}) is a family — the factory receives one arg and builds a static interceptor for it; it is referenced as [id arg]. Reach for :factory only when the same behaviour needs per-reference configuration (the framework's standard :rf.interceptor/path is the canonical example).
  • Reference, never inline. Event and frame :interceptors chains carry interceptor references — a bare keyword or an [id arg] 2-vector — never inline interceptor values (002 §Event and frame chain grammar). An inline interceptor map / Var / ->interceptor result in a public chain is a registration error (:rf.error/inline-interceptor-removed). ->interceptor is not the public authoring form — reg-interceptor is.

The descriptor shapes:

reg-interceptor takes (id ?metadata descriptor). The descriptor is exactly one of:

{:before before-fn}            ;; static: runs before the handler
{:after  after-fn}             ;; static: runs after the handler (reverse chain order)
{:before before-fn :after after-fn}
{:factory factory-fn}          ;; parameterized family; factory-fn takes ONE arg

A descriptor mixing :factory with :before / :after is ambiguous and rejected; any other malformed descriptor is :rf.error/invalid-interceptor at registration. Each :before / :after fn is (context) -> context: read inputs from the context's coeffects (get-coeffect), write outputs to its effects (assoc-effect), or rewrite coeffects with assoc-coeffect / update-coeffect (per 002 §Interceptor chain execution).

Template — static {:before} (read/rewrite coeffects):

(rf/reg-interceptor :audit/record-event
  {:doc "Append each handled event's id to the app-db audit trail."}
  {:before
   (fn before-audit-record-event [ctx]
     ;; Read the dispatched event from coeffects; rewrite the :db coeffect.
     (let [[event-id] (get-in ctx [:coeffects :event])]
       (update-in ctx [:coeffects :db :audit/events] (fnil conj []) event-id)))})

Template — static {:after} (inspect/rewrite effects):

(rf/reg-interceptor :metrics/count-writes
  {:doc "Increment a write counter whenever a handler returned a :db effect."}
  {:after
   (fn after-metrics-count-writes [ctx]
     (if-let [db (get-in ctx [:effects :db])]
       (assoc-in ctx [:effects :db] (vary-meta db update :write-count (fnil inc 0)))
       ctx))})

Template — parameterized {:factory} (an [id arg] family):

;; A factory that gates a handler on a role read from the event payload.
;; Referenced as [:app/role {:role :admin :redirect [:login/show]}].
(rf/reg-interceptor :app/role
  {:doc "Require the dispatched event carry the named role; else dispatch :redirect."}
  {:factory
   (fn factory-app-role [{:keys [role redirect]}]      ;; ONE arg — a composite map
     {:before
      (fn before-app-role [ctx]
        (let [[_ payload] (get-in ctx [:coeffects :event])]
          (if (= role (:role payload))
            ctx
            (assoc-in ctx [:effects :fx] [[:dispatch redirect]]))))})})

The factory takes exactly one argument. A behaviour needing several inputs takes them as a single composite arg (a vector or map), as above. A bare-keyword reference to a :factory id — or an [id arg] reference to a static id — is :rf.error/interceptor-factory-arity.

Referencing the interceptor in a chain:

;; Bare keyword references a static interceptor; [id arg] references a factory.
(rf/reg-event :cart/add
  {:interceptors [:auth/required
                  :audit/record-event
                  [:rf.interceptor/path [:cart]]]}        ;; standard path interceptor (a factory)
  (fn handler-cart-add [{:keys [db]} [_ sku]]
    {:db (update db :items conj sku)}))

;; Frame-level chains prepend to every event handled in the frame:
(rf/make-frame {:id :story/cart
                :interceptors [:story/record-events]})

The standard :rf.interceptor/path is the one framework-shipped interceptor (a :factory): [:rf.interceptor/path [:cart :items]] focuses a handler on an app-db sub-slice and re-widens the result — you do not register it (002 §Standard :rf.interceptor/path). There is no standard unwrap / trim-v; ordinary handler destructuring covers those (and keeps the :event coeffect stable for tracing/replay).

Per-frame / per-call override (reference-based):

;; In a story/test, swap or remove an interceptor by its exact reference:
(rf/dispatch [:cart/add "ABC-1"]
  {:frame f
   :interceptor-overrides {:auth/required :story/skip-auth   ;; replace with another ref
                           :audit/record-event nil}})         ;; nil removes it

Override keys are interceptor references, matched by exact canonical reference (a bare keyword matches that keyword or an entry's id; an [id arg] vector matches only that exact reference). Values are another reference (replace) or nil (remove) — no inline interceptor values (002 §:interceptor-overrides).

Pattern-level discipline (per 001 §Interceptors and 002 §Registered interceptors and the chain grammar):

  • Reference-only chains. Authored behaviour lives in exactly two homes — event metadata and frame metadata — and both carry refs. Dispatch opts do not accept an additive :interceptors key; per-dispatch variation is :interceptor-overrides.
  • :before short-circuits, :after always runs. A :before may queue the rest of the chain off (skipping later :befores and the handler); every entered interceptor's :after still runs, in reverse order. The execution model is unchanged from v1.
  • The reference resolves at dispatch time — re-registering an interceptor id with a new descriptor takes effect on the next dispatch of any event whose chain references it; the event does not have to be re-registered (hot reload).
  • Migration boundary only: reg-interceptor also accepts an existing interceptor value carrying implementation-private slots (an :id, if present, must match the registration id). This is confined to the reg-interceptor call site; public chains still carry refs.

Smoke test (headless via a test frame):

(deftest audit-record-event-test
  (rf/reg-interceptor :audit/record-event
    {:doc "Append each handled event's id to the audit trail."}
    {:before
     (fn [ctx]
       (let [[event-id] (get-in ctx [:coeffects :event])]
         (update-in ctx [:coeffects :db :audit/events] (fnil conj []) event-id)))})
  (rf/reg-event :feature/touch
    {:interceptors [:audit/record-event]}
    (fn [{:keys [db]} _] {:db db}))
  (rf/with-new-frame [f (rf/make-frame {})]
    (rf/dispatch-sync [:feature/touch] {:frame f})
    (is (= [:feature/touch] (:audit/events (rf/app-db-value f))))))

AI-first checklist:

  • Interceptor id is a single namespaced keyword and unused ((rf/registrations :interceptor)).
  • The descriptor is exactly one of {:before} / {:after} / {:before :after} / {:factory} — no :factory+:before/:after mix.
  • A :factory factory takes exactly one argument (a composite vector/map when it needs several inputs).
  • :before / :after fns are (context) -> context, pure with respect to the context map; side-effects are expressed as effects (write [:effects :fx] via assoc-in / update-in), never performed in the body.
  • :doc is present and one sentence.
  • The interceptor is referenced from chains by id — bare keyword (static) or [id arg] (factory) — never inlined as a value.
  • Overrides (in stories/tests) are reference-valued (another ref or nil), never inline values.
  • :before / :after fns have meaningful names (not fn); the name shows up in stack traces and tooling.
  • Smoke test asserts the interceptor's effect on a real dispatch through a test frame.

Example — auth gate referenced across several events:

(ns my-app.auth.interceptors
  (:require [re-frame.core :as rf]))

(rf/reg-interceptor :auth/required
  {:doc "Redirect to login when no user is authenticated; let the handler run otherwise."}
  {:before
   (fn before-auth-required [ctx]
     (if (get-in ctx [:coeffects :db :auth :user])
       ctx
       ;; Unauthenticated: stage a redirect. (Throwing from :before short-circuits
       ;; the remaining :before stages and the handler — see 002 §Interceptor chain
       ;; execution; a redirect-and-continue gate stages an :fx instead, as here.)
       (assoc-in ctx [:effects :fx] [[:dispatch [:rf.route/navigate {:to :route/login}]]])))})

;; Any event that needs the gate references it by id:
(rf/reg-event :account/update-profile
  {:doc          "Update the signed-in user's profile."
   :interceptors [:auth/required]}
  (fn handler-account-update-profile [{:keys [db]} [_ patch]]
    {:db (update db :profile merge patch)}))

Cross-references

Worked examples (each prompt, in action)

The 7GUIs example series and the login example demonstrate every prompt in working code (the examples/ tree is test-free per examples/README.md; real-regression coverage lives in the substrate contract tests, the framework gates, and adapter-level smoke tests):

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 — one shared .cljc application (events, subs, views, plus both entry points: server handle-request returning HTML+payload, and client run doing :rf/hydrate seeding), beside a small registration-free mount.cljs holding the client's adopt-vs-fresh React-root decision so the browser DOM-adoption regression drives the same branch the example ships; JVM-runnable smoke test
CP-10 (story) The Story CLJS-unit tutorial walks a reg-story + reg-variant + run-variant-driven deftest end to end; the Xray panel-gallery testbeds register real story/reg-story / reg-variant artefacts across the panel surface (Story lives in tools/story/, not examples/)
CP-11 (interceptor) Circle Drawer registers the :undoable static interceptor ({:before :after}) and references it by id from undoable events. (RealWorld's auth gate is NOT an interceptor — auth-on-a-route is the declarative :can-enter guard per 012 §Navigation blocking; interceptors are for cross-cutting policy spanning many routes.)