Simulator.Company App Generator
You turn a set of existing Corezoid processes + a product description into a deployed, tested, multi-page Smart Form.
You are an orchestrator. You own three things nobody else does:
- Contract extraction — deriving what each process really consumes and produces.
- App design — turning N process contracts into a coherent page map that uses them all.
- Verification — proving the result actually works, and repairing it when it doesn't.
Everything else you delegate:
| Concern | Delegate to |
|---|---|
| Page layout, locale, viewModel, push/deploy | simulator-smart-forms |
| The Corezoid backend contract and brief format | simulator-smart-forms-logic |
| Authoring / editing the actual process JSON | corezoid:corezoid-create / corezoid:corezoid-edit |
| Less/CSS theme | simulator-styles |
| Driving pages during the E2E test | simulator-smart-forms-runtime |
Reply to the user in their own language; keep this document's logic intact.
0. Preflight
Confirm before doing anything else:
-
Corezoid plugin present — you need both halves:
- the MCP tools
pull-process,push-process,lint-process,layout-process,create-process,run-task,create-api-key,find-principal,share-object; - the skills
corezoid:corezoid-createandcorezoid:corezoid-editin the available-skills list — they author the middleware in §7, and there is no fallback.
If either is missing, stop and tell the user to install
github.com/corezoid/corezoid-ai-pluginand run/corezoid-init. - the MCP tools
-
Simulator authenticated — a workspace is selected (
/simulator-initotherwise). -
You know the
companyId(Corezoid workspace id, a string — a UUID or ani…-prefixed id) — needed at binding time. Ask if unknown.
Do not hand-author .conv.json files yourself when the Corezoid plugin is absent.
1. What this skill accepts
| Input | Form |
|---|---|
| Process ids (primary) | a list of numeric Corezoid process ids |
| Folder id | one Corezoid folder id — pull it and use every process inside |
| Local paths | already-pulled <ID>_<Title>.conv.json files |
| Product description (required) | free text: who the users are, what the app is for |
| Design intent (recommended) | brand palette / logo / a reference site or app, and the device mix |
If the description is missing or vague, ask once for: who uses the app, what they should be able to do, and whether it needs a login. Do not start designing without it.
Ask for the design intent in the same breath — brand colours or a logo, a reference app or site to aim at, and whether the audience is phone-first. One extra question here is the whole difference between an app that looks designed and one that looks generated; §5.3a turns the answer into a design brief. If the user has no preference, state the defaults you will use rather than leaving them implicit — an unstated default is how a design decision gets made by accident.
2. Phase 1 — Acquire
Run pull-process(process_id=<id>) for every id. It writes
<ID>_<Title>.conv.json into a directory mirroring the process's location in Corezoid
(resolved from parent_id).
Never rename these files. The
<ID>_prefix is load-bearing —push-processandlint-processrecover the process id from it and fail with a format error otherwise.
Record the local path for each process; you will re-read them throughout.
3. Phase 2 — Contract extraction
This is the analytical heart of the skill. Produce one contract manifest
(app-contract.json) describing every process. Work from the file, not from assumptions.
3.1 The trap: declared params drift
Each .conv.json has a root params[]:
jsonc"params": [ {"name":"phone", "type":"string", "flags":["required","input"], "descr":"phone"}, {"name":"bonusAmount", "type":"string", "flags":["output"], "descr":"code :200"} ]
This is the declared contract and it drifts. In the reference 12-process set, five
processes declare zero outputs yet clearly reply with data, and one declares an output no
reply node ever sets. Treat params as a hint and a cross-check, never the source of
truth.
3.2 Outputs — derive them from api_rpc_reply nodes
Walk scheme.nodes[].condition.logics[] and collect every logic with
type == "api_rpc_reply":
jsonc{ "type": "api_rpc_reply", "mode": "key_value", "res_data": {"code":"200","result":"success","transactionList":"{{transactionList}}"}, "res_data_type": {"code":"string","result":"string","transactionList":"array"}, "throw_exception": false }
modeis"key_value"in practice (a"keys"array form exists — handle it if seen).- Every value in
res_datais a string, even whenres_data_typesaysarray/object. A"{{var}}"value means "produced upstream"; a bare literal is a constant.
3.3 The envelope convention — group replies into three sets
Every process in the reference set replies {result, code, ...payload} with
result ∈ success | error | recovery | registration and code ∈ 200 | 400 | 401 | 402 | 403.
Group the reply nodes:
| Set | Match | Meaning for the app |
|---|---|---|
| success | result:"success" or 2xx code | The union of its payload keys (minus code/result) is the real output schema |
| alternate | any other non-error result (recovery, registration, …) | A control-flow outcome, not a failure → becomes a 302 navigation edge |
| error | result:"error" | Boilerplate → becomes an error notification |
Alternate outcomes are the most commonly missed signal. The reference auth process has 8 reply nodes:
200/success(withtoken,cardCode,bonusAmount,QR),402/registration,401/recovery, and several403/error. A design that only reads the 200 branch silently loses the entire registration and password-recovery flows.
3.4 Types
Take types from res_data_type. They are inconsistent between sibling reply nodes in the
same process (code typed "number" in one node and "string" in five others; bonusAmount
typed both ways). Majority-vote and move on; do not treat a conflict as an error.
3.5 Array element shape
To render an array as a table you need its element structure, which res_data_type: "array"
does not give you. Three tiers, in order:
- Reply-node
description— often holds a pretty-printed example response, including nested arrays. This is the richest static source. Measured availability in the reference set: 18 of 36 reply nodes — roughly half, so treat it as opportunistic. - Upstream trace — follow the
{{var}}back to theapi_code/set_param/api_rpcthat produced it; a Code node often reveals the shape. - Live probe — Phase 2b below.
If all three fail, record "elementShape": "unknown" and design that page with a
contentLoop of labels driven by whatever keys appear at runtime, or ask the user.
3.6 Inputs
Real inputs = params[flags ∋ input] ∪ {{placeholders}} that are consumed before being
produced. Payloads live in three different fields — scan all of them, or you will report a
process as input-free when it is not:
| Node | Payload field |
|---|---|
api_rpc, set_param | extra / extra_type |
api_copy | data / data_type — there is no extra field at all |
api with format:"raw" | raw_body — a JSON string; extra is {} |
jsonc{"type":"api_rpc","conv_id":1760254, "extra":{"lat":"{{location_lat}}","lon":"{{location_lon}}","radius":"200000"}}
location_lat / location_lon are inputs; radius is a constant. Carry the required flag
from params where present.
Two traps worth checking before you design a field for an input:
- A declared input can be dead. If
{{thatParam}}appears in no node, the value comes from somewhere else — typically a state-diagram read like{{conv[<id>].ref[SessionData].Session}}, which is one workspace-wide session rather than a per-user token. Drop it from the manifest and record why. {{proc[<id>].ref[<key>].<field>}}is data, not input — except for a nested inner placeholder (…ref[Cities].{{cityCode}}), where the inner one is an input.
3.7 Side-effect classification
Side effects are not statically obvious. In the reference set Send complain and
Send calback mailing look inert at this layer but fan out via api_copy to a mailing
process, and Cashback Categories sends Telegram/Viber messages. Classify each process
likely | unlikely | unknown from api_copy presence, mutating verbs in the URL path or callee
name of an outbound call (create, set, send, register, update, delete, pay), an
outbound reply that nothing downstream reads, and title/description keywords (send, mail, register,
recovery, complain, notify, create).
The HTTP method is a weak signal on its own — do not classify on it. These backends POST to read: the reference
Transactions historyfetches its rows withapi POST {{ApiURL}}/chatbot/getTransactionsand mutates nothing, which is why §5.2 puts it on a page's/get. Treat "non-GET" as strong and every reader scoreslikely, §5.1a then bans every content page from/get, and the rule stops being followed at all. Read the path and the payload:getTransactionsis a read;setClientFieldsis not.
Score only the nodes reachable from Start. Corezoid never prunes orphans, so score the
subgraph reached by BFS over to_node_id + err_node_id + semaphors[].to_node_id. In the
reference set Cashback Categories has 126 nodes and 6 reachable ones — the other 120 are dead
chatbot senders. Over the whole bag it scores likely and gets excluded from probing; over the
reachable set it is unlikely and probes safely. The same walk stops you deriving outcomes from
unreachable reply nodes (Authorization + Bonus amount has a duplicate 200/success reply that
nothing reaches). Record "nodes": "<reachable> of <total> reachable" in the manifest, and flag a
lopsided ratio to the user — it usually means the process was repurposed and its declared params
describe the old job.
This classification gates two things: probing (§4) and where the process may be wired (§5.1a).
Never treat unlikely as proof of safety.
3.8 The manifest
jsonc{ "processes": [{ "id": 1760349, "title": "Authorization + Bonus amount", "path": "689413_API2/1760349_Authorization_+_Bonus_amount.conv.json", "inputs": [{"name":"phone","type":"string","required":true}, {"name":"cardPassword","type":"string","required":true}], "outcomes": { "success": {"code":"200","keys":{"token":"string","cardCode":"string", "bonusAmount":"number","QR":"string"}}, "alternate": [{"code":"402","result":"registration"}, {"code":"401","result":"recovery"}], "error": [{"code":"403"}] }, "arrays": {}, "nodes": "23 of 25 reachable", "sideEffects": "unknown", "declaredParamsMatch": false, "deadDeclaredInputs": [] }] }
3.9 Report to the user
Show a contract table before designing, and explicitly flag:
- processes with no derivable outputs (nothing to render),
- arrays with unknown element shape,
- the side-effect classification, and the reachable/total node ratio wherever it is lopsided — that is the signal the process was repurposed,
- any place
paramsdisagreed with the reply nodes (declaredParamsMatch: false), including declared inputs that no node consumes (deadDeclaredInputs).
State the evidence for any claim you make about the backend. Saying "this process is broken"
is a finding about someone else's system, and the payload-carrier traps in §3.6 make it easy to
get wrong — an api_copy read through extra looks like it sends nothing. Before reporting a
defect, confirm you read the right field (data for api_copy, raw_body for a raw api), and
name the field you read.
4. Phase 2b — Ground-truth probing (opt-in, user-gated)
run-task(process_path, data) runs a task on the deployed process and waits for a final
node, so it returns the process's real reply — the only reliable way to resolve an
unknown array shape.
Never probe automatically. Present the side-effect classification, let the user choose which processes are safe to call with test data, and probe only those. Calling a process blind can send a real SMS, email, or Telegram message to a real customer.
Feed anything learned back into the manifest before designing.
5. Phase 3 — App design
Turn N contracts into a page map. Produce app-plan.md and get it approved before building.
5.1 Clustering heuristics
| Contract shape | Becomes |
|---|---|
| Emits a token / session key, takes credentials | Entry / login page (the app's index) |
Chained by a shared key (process B's input = process A's output, e.g. cardCode) | A wizard: one page per step |
| Array output, few or no inputs | Content page — a table or a contentLoop list |
| Input-heavy, output-poor | Form page with a submit button |
An alternate outcome of another process (401 recovery, 402 registration) | Its own page, reached by 302 nextPage |
| No inputs, static-ish output (FAQ) | Content page; consider is_static only if it needs no Corezoid call at all |
5.1a A side-effectful process never goes on a page's /get
/get is not a one-off call. It fires on every page open, on every re-render, for every
visitor, unattended — and a crawler, a refresh or a shared link fires it again. So wiring a
process to /get is repeated automatic probing, which is exactly what §4 makes you ask the
user about before doing once.
Rule: a process classified
likelyorunknown(§3.7) may only be reached from a/sendbutton. Onlyunlikelymay sit on a/get.
This bites hardest on a wizard whose first step looks like a read. In the reference set
Registration : 1 step - Get fields reads like a getter and was classified unknown; it turned
out to allocate a real loyalty card number from a shared backend session on every call.
Wired to the reg page's /get, it burned a live card number every time anyone opened the page —
including each L3 test render.
The fix is cheap when you design for it and annoying afterwards: give the wizard an explicit
start button (<verb>_start_btn) whose /send branch makes the call and returns the result
as changes[]. The page's /get then renders step 1 statically. You get a real three-step
wizard instead of a page that mutates on sight.
When a likely/unknown process genuinely has no button to hang it on, say so and ask — do not
quietly put it on /get.
5.2 The coverage rule (load-bearing)
Every input process must appear in the map. Emit an explicit coverage table:
| Process | Page | Trigger |
|---|---|---|
| 1760349 Authorization + Bonus amount | index | button login_btn |
| 1760347 Transactions history | history | page /get |
| … | … | … |
If a process genuinely does not fit the product description, say so and ask — offer to drop it, give it a plain page, or reshape the app. Never silently omit one; silent omission is the main way this skill can produce a wrong result that still looks finished.
5.3 Per-page specification
For each page define: id, title, backing process(es), fields (id / class / type /
required), buttons (id → action), viewModel keys, which hidden session carriers it
needs, and its outgoing navigation edges.
Naming conventions that keep the layers in sync:
- viewModel key ↔
{{key}}inpages/<id>/config— same name, no aliasing. - A table's body binds as
"body": "{{<page>_<entity>_body}}". - Buttons:
<verb>_btn(login_btn,submit_complaint_btn). - Hidden session carriers:
__-prefixed (__token,__cardCode).
5.3a The design brief — the look is decided here, not in Phase 8
Phase 8 (§10) makes the app branded. It cannot make it designed: archetype, density,
hierarchy and which states exist are all fixed by the page configs you author in Phase 4, and by
then a stylesheet can only recolour what the JSON already committed to. So app-plan.md carries
a short design brief next to the page map, and it goes through the same gate.
One screen is enough, but every row has to have an answer:
| Decision | Concretely |
|---|---|
| Archetype per page | auth card (pg-auth, ~428px) / content list / table / wizard step / form — this fixes the styleClass set of §6.1 |
| Tokens | brand, ink, surface, line, one colour per status, and the font. They land in styles/colors_fonts in Phase 4, provisional values included |
| Spacing scale | one scale (e.g. 4-8-12-16-20-32) for the whole app. Ad-hoc per-page padding is the single biggest reason a generated app reads as assembled rather than designed |
| Type scale | 3–4 sizes with roles (page title / card title / body / meta). Past four, the hierarchy stops being one |
| Density & device | phone-first or desktop-first, and which breakpoints you honour — simulator-styles uses 375 / 820 / 1180 |
| Every state of every data page | loading, empty, error, no-session. §7.7 puts a 30 s floor on the api_rpc semaphor, so the bad case is a real half-minute wait, not a flicker — and per §6 you express these as text, never as visibility |
| Component inventory | the ≤6 components the whole app is built from (button, input, card, table row, chip, toast). One look each, reused everywhere |
The brief is also what keeps Phase 8 from turning into a rewrite: tokens and styleClass hooks
that arrive after nine page configs exist mean editing all nine again — the same argument §6.1
makes for styleClass.
5.4 Approval gate
Show the page map, the coverage table, the navigation diagram and the design brief. Get an explicit yes before building anything. This is the last cheap moment to change the design — visual included: after Phase 4 a layout change is nine config edits, not one.
6. Phase 4 — Build the Smart Form shell
createSmartForm(title="…", ref="<slug>") // credentials omitted ON PURPOSE — see §8 pullSmartForm(actorId="<uuid>") // writes .manifest.json that push needs … author develop/pages/<id>/{config,locale}, develop/locale, develop/viewModel … pushSmartForm(actorId="<uuid>") // fix validationErrors, repeat until clean
Edit develop only — production is readonly. Follow simulator-smart-forms for the
page-config grammar (grid → forms → sections → items).
Seed every {{key}} in develop/viewModel with a sane default. A page whose viewModel
key is never defaulted renders a literal {{key}} if the backend call fails — and the L3
test below checks for exactly that.
One exclusion: placeholders inside a contentLoop section's content are loop-scoped, not
viewModel keys. They are substituted from the loop entries the backend returns, so seeding them
is wrong — a default would shadow nothing and a "missing default" report on them is a false
positive. In the reference promo page, {{img}} / {{text}} / {{url}} live only inside the
loop template; {{promos_loop}} (the array binding itself) is the viewModel key that needs the
default ([]). The same exclusion applies to anything you write that audits these tokens.
Decide the app's language here, once — before you write a single Code node. You author two sources of user-visible text and only one of them is translated:
| Source | Localised? |
|---|---|
[[key]] resolved from locale / pages/<id>/locale | yes — per the viewer's language |
string literals inside the middleware's Code nodes (changes[].value, notifications[].title, every empty state) | no — they bypass the locale layer entirely |
So a locale carrying en + uk plus Code nodes hardcoded in one language renders a mixed page
for anyone whose language resolves to the other: in the reference build the home card showed
"Your bonuses" directly above "Сесія не знайдена". Nothing flags it — both layers are
individually valid, pushSmartForm is clean, and appGetPage returns exactly what you asked for.
Pick one and write it into the brief:
- single-language app — give every locale key the same text under every language code, so the viewer's locale cannot pull the page away from the Code nodes. Right answer when the product has one audience;
- genuinely multilingual — read
body.context.languagein the/getprep node and branch every literal, or return locale keys from the backend and let the page resolve them.
Emitting en + uk locale files and then hardcoding one language in the backend is the option that
looks finished and is not.
Three renderer constraints shape every page you author. Design around them from the start; discovering them mid-build forces a redesign of every empty state:
| Constraint | Consequence |
|---|---|
visibility cannot be a {{placeholder}} — it is validated against the literal enum at push time | A page cannot vary visibility on /get at all. Dynamic visibility exists only via changes[] on /send. Do not emit *_visibility viewModel keys for /get; they do nothing. |
A label value may never be "", nor an image value an empty src | Express "nothing to show" as a non-breaking space (\u00a0) for labels. For an image use a fetchable http(s) placeholder URL — not a data: URI: the renderer proxies every image.value through /api/1.0/image?src=, which answers 400 "URL is not allowed" for that scheme |
A changes[] patch cannot touch extra | Anything configured through extra is fixed for the life of the rendered page; pick components whose state lives in value |
Together the first two mean empty states are driven by text, not by visibility: bind a
label to a viewModel key and let the backend decide whether it says anything.
6.1 Write the baseline stylesheet NOW, not in Phase 8
Two platform defaults make an unstyled generated app look broken rather than plain. They are not cosmetic and they are not deferrable — a page authored without knowing about them gets a layout you have to redo:
| Default | What you actually see | Fix |
|---|---|---|
Every .section__content ships its own grey background + padding: 20px 16px 0 + margin-bottom: 20px (some rules theme-scoped → 0,2,0) | a grey padded box inside every card you author, with doubled padding | neutralize once, then let your own card supply padding |
[data-class="grid-one-column"] is capped and centred | every page squeezed into the platform's narrow card, no app shell | unlock it and cap the width yourself |
So Phase 4 ships develop/styles/ from the start. The minimum that makes a generated app read as
an app rather than a broken form:
less// styles/index — imports only, in cascade order @import "colors_fonts"; // tokens first: init_styles AND every pages/<id>/style inherit them @import "init_styles";
less// styles/init_styles — the mandatory part *, *::before, *::after { box-sizing: border-box; } & { background: @bg; min-height: 100vh; color: @ink; font-family: @font_main; } // 1. the grey box inside every card .section .section__content { background: transparent !important; margin-bottom: 0 !important; padding: 0 !important; } .label, .button, .form { margin: 0 !important; } .button-wrapper { width: auto !important; display: block; } // 2. the app shell .content__main { padding: 0 !important; } [data-class="grid-one-column"] { max-width: none !important; } .pg { max-width: 980px; margin: 0 auto; padding: 20px 20px 40px; } // grid.styleClass = "pg pg-<id>" .pg.pg-auth { max-width: 428px; } // login / recovery / register // 3. the utility the hidden session carriers of §7.4 depend on .visually_hidden { position:absolute; width:1px; height:1px; margin:-1px; padding:0; border:0; white-space:nowrap; clip-path:inset(100%); clip:rect(0 0 0 0); overflow:hidden; }
Put the design brief's tokens into colors_fonts now, provisional values and all. Everything
downstream inherits them (init_styles and every pages/<id>/style, with no @import), so a
placeholder palette here is one file to edit in Phase 8, while a missing one is a sweep through
every sheet you wrote in between.
Give every page's grid a styleClass ("pg pg-<pageId>") while authoring the config — it is
the only hook that lets the stylesheet treat auth screens differently from app screens, and adding
it later means touching all nine configs again.
Two authoring notes that only matter if you know them up front:
- A
styleClasson arowis dropped. Therowvalue is space-separated, sorow: "geo geo_row"puts a literal.geo_rowon the row wrapper — that is the only way to style a multi-column row (e.g. to align a button with the inputs beside it, or to stack them under a breakpoint). - A bare
contentLooprenders its iterations flat, with no per-iteration box. Setting the section'ssortable: truemakes the server wrap each iteration in adraggable— see §6.2 — which is how you get a card grid. Decide this while authoring, not after.
6.2 contentLoop bound from the viewModel — verified shapes
Both of these are confirmed against a live render, so design on them freely:
-
"contentLoop": "{{promos_loop}}"works exactly like a table's"body": "{{rows}}"— the backend returns an array of plain variable bags and the server expands the section'scontenttemplate once per entry. -
sortable: true+contentLoopmakes the server expand each iteration into adraggablewrapper instead of flat items:jsonc// served config, per iteration {"id": "<sectionId>-cl-0", "class": "draggable", "items": [ /* the template, substituted */ ]}That wrapper is what you style as a card (
.draggable__content). Hide.draggable__handleto drop the drag affordance — it is the only activator, so there is no reorder submit to handle:less.promoloop .section__content { display: grid !important; grid-template-columns: repeat(auto-fill, minmax(258px, 1fr)); gap: 20px !important; } .promoloop .draggable__handle { display: none !important; } .promoloop .draggable { border:0 !important; background:transparent !important; padding:0 !important; box-shadow:none !important; cursor:default !important; } .promoloop .draggable__content { background:@surface; border:1px solid @line; border-radius:18px; overflow:hidden; display:flex; flex-direction:column; height:100%; }
Hand the rest — palette, typography scale, component re-skinning — to simulator-styles in Phase 8.
That phase is for making it branded; this one is for not shipping something broken.
7. Phase 5 — Build the middleware process
One Corezoid process is bound to the Smart Form env (procId) and is the only bridge
between the form and the domain processes.
You do NOT author this process yourself
Never hand-write the middleware
.conv.json. Your job is to produce a precise brief and hand it to the Corezoid plugin's authoring skill, which ownscreate-process→ node authoring →layout-process→lint-process→push-process:Skill(skill="corezoid:corezoid-create", args="<the brief from §7.8, verbatim>")Use the plugin-qualified name
corezoid:corezoid-create. If the host reports it as unknown, retry with the barecorezoid-create(older hosts resolve unqualified names). For changes to a middleware that already exists — including every fix in the repair loop of §9.4 — usecorezoid:corezoid-editthe same way, never a hand edit.
Read $CLAUDE_PLUGIN_ROOT/skills/simulator-smart-forms-logic/SKILL.md §1–§2 first: it
defines the /get and /send contract and the reusable node fragments (§2.1–§2.9) that
your brief should reference by number instead of restating.
7.1 Topology
Default: one bound middleware process that owns both paths and calls each domain process
via api_rpc. The domain processes already carry the real work — the middleware is a
router, so keeping it in one graph keeps it debuggable.
Split into per-page sub-processes (called with api_copy / api_rpc, each aliased) only
when the single graph exceeds roughly 60 nodes or 8 pages. Say which you chose and
why.
Count business nodes against that budget, not error nodes. Escalations and Error finals scale
mechanically with the number of fallible nodes — layout-process measured 32% of both handlers
in the reference build as error nodes (77 nodes → ~52 business; 74 → ~50). Counting them makes you
split a graph that is comfortably inside the threshold. Count Start, dispatches, domain calls,
builders and callbacks; ignore the error clusters.
Two levers keep a graph small without splitting it — both in §4.1a of
docs/user-flows/app-generation.md: one Code node for all navigation (a lookup table instead of
a dispatch arm per nav_* button), and one Code node per outcome mapping instead of a
go_if_const tree per domain call. The second also removes the unbracketed-nested-param silent
fallthrough entirely, at the cost of hiding that branching from the Corezoid canvas — so keep the
path / page / buttonId dispatches as real condition nodes and use Code nodes only for the
mapping inside a branch.
7.2 The shape of the graph
Start └─ Condition on `path` (go_if_const) ├─ /get → Condition on `body.page` │ └─ per page: api_rpc(domain) → [namespace, if 2+ calls] → api_code(build viewModel) │ → Callback GET ← THIS branch's own api node └─ /send → api_code(extract body.buttonId) └─ Condition on `body.buttonId` ├─ <button ids> → api_rpc(domain) → map result/code │ ├─ success → build changes/notifications │ ├─ alternate → build 302 nextPage │ └─ error → build error notification │ → Callback SEND ← THIS branch's own api node └─ <submitOnChange ids> → cascade update or empty ack → Callback SEND ← and this one's, too
⚠️ The callback node is per branch, not per path. Duplicating the identical
apinode into every page branch and every button branch is deliberate: if nine branches converge on one callback, that callback's single error target is fed by nine escalations andlint-processreportsSHARED ERROR CLUSTERS. Same for the Error finals — each fallible node needs its own (§7.7). The one node every branch may share is the plainobj_type: 2Success final, which holds no logic. This is why error nodes come out around a third of the graph, and why §7.1 tells you to budget business nodes only.
Both callback nodes are api nodes POSTing to {{__callback_url}}:
jsonc// GET {"type":"api","method":"POST","url":"{{__callback_url}}","rfc_format":true, "content_type":"application/json", "extra":{"code":"200","viewModel":"{{viewModel}}"}, "extra_type":{"code":"number","viewModel":"object"}, "extra_headers":{"content-type":"application/json; charset=utf-8"}, "response":{"header":"{{header}}","body":"{{body}}"}, "response_type":{"header":"object","body":"object"}, "customize_response":false, "format":"","send_sys":true,"debug_info":false,"cert_pem":"","max_threads":5, "is_migrate":true,"err_node_id":"<id>","version":2}
⚠️
format,send_sys,debug_info,cert_pem,max_threadsare required — do not trim them. Omitting them makeslint-processfail the file twice:max_threadsis a JSON-schema required property (missing property 'max_threads'), and the rest tripUNDERSPECIFIED API CALL NODES, whose real-world symptom is a server commit that hangs ~15–20 s and then reportsno response from serverinstead of a useful error.
/send is identical with extra:{"code":"200","data":"{{responseData}}"} and
extra_type:{"code":"number","data":"object"}.
customize_response:falseis mandatory — cb-apigw acks with an empty body, and response casting then throwsapi_wrong_convert_parameven though delivery succeeded.
7.3 Namespace isolation (the subtle one)
An api_rpc callee's res_data merges into the caller's task data at top level. Because
every domain process replies with the same result / code envelope, calling two of them in
one task means the second silently overwrites the first's verdict.
Rule: in a task that calls more than one domain process, every api_rpc is immediately followed
by a set_param that copies the results into namespaced keys and clears the shared envelope.
jsonc{"type":"set_param", "extra":{"auth_result":"{{result}}","auth_code":"{{code}}", "auth_token":"{{token}}","auth_cardCode":"{{cardCode}}", "result":"","code":""}, "extra_type":{"auth_result":"string","auth_code":"string", "auth_token":"string","auth_cardCode":"string", "result":"string","code":"string"}, "err_node_id":"<id>"}
Branch on {{auth_result}} / {{auth_code}}, never on the bare {{result}}.
Scope the rule to where the collision can actually happen. The hazard is two callees writing the same envelope into one task. So:
- a task that calls two or more domain processes → namespace after every call, no exceptions;
- a branch that calls exactly one domain process and then reads its reply → there is nothing to
overwrite. Read
{{result}}/{{code}}/ the payload keys directly in the builder Code node and skip theset_param.
Applying it unconditionally is not "safe by default" — it is a real cost. On a 9-page /get router
where each page branch makes a single call, the mandatory version adds a set_param plus its own
error cluster per branch: ~18 nodes of pure ceremony. Decide per branch, and if you skip it, say so
in the brief so the next editor knows to add it the moment a second call appears.
If a Code node reads the values, author the node as api_code rather than set_param.
lint-process cannot parse JavaScript, so it resolves a set_param's outputs by scanning
downstream node configuration; a data.result read inside an api_code is invisible to it and
the file fails with UNUSED SET_PARAM … but no downstream node references them. This bites exactly
when you combine this section with the "one Code node per outcome mapping" pattern (§4.1a of
app-generation.md) — which is the recommended shape. Same assignments, different node type:
data.auth_result = data.result; data.result = "";
7.4 Hidden session carriers
The Smart Form protocol is stateless: /get and /send carry no server session. Carry state
in hidden form fields.
- Put a CSS-hidden
edititem in every form that needs it, withvisibility: "visible"— a field hidden by CSS butvisiblein the protocol is still submitted. Give themstyleClass: "visually_hidden".jsonc{"id":"__token","class":"edit","type":"text","value":"{{__token}}", "visibility":"visible","styleClass":"visually_hidden"} /getseeds them from the viewModel it returns./sendreads them frombody.data.__token— always frombody.data, never frombuttonData.- The login handler populates them by returning
changes[]entries for those ids before navigating.
⚠️ A submit sends exactly ONE form, so a carrier only arrives if it sits in the SAME form as the button. "Per page" is the wrong unit and it fails silently. The shape that bites is the obvious one: an
appbarform holding thenav_*buttons, and the carriers over in the content form. Every nav click then submits a form with no value-bearing items at all,body.datais{}, and the handler writes an empty session into its302 query— the user is quietly logged out on the first click. Nothing catches it:pushSmartFormis happy,appGetPagerenders the page correctly, and only the navigation is broken.So read the session defensively instead of relying on placement:
javascriptvar d = (data.body && data.body.data) || {}; var q = (data.body && data.body.query) || {}; data.sess_token = String(d.__token || q.token || "");
body.queryis present on/sendtoo (the page URL holds it), so this works no matter which form the button ended up in — and it keeps working when someone later moves a button. Duplicating the hidden fields into every form also works, but it rots the moment a form is added.
Never put a password or a payment token in a carrier — carriers reach the browser. If a contract forces one, tell the user and propose an actor-backed session instead.
⚠️ The
body.queryfallback above means the value also lives in the page URL — history,Referer, proxy logs, and any link the user shares. A domain session token belongs in that class by default; do not judge per app whether it is "sensitive enough". The shape to reach for first is a session kept in a state process or an actor, keyed by an opaque short-lived id, with only that id in the query. Carry a real bearer token in the URL only when the given backend leaves no alternative, and only after telling the user that is what you are doing — see "Carrying a session across pages" in$CLAUDE_PLUGIN_ROOT/docs/user-flows/app-generation.md§4.1a.
7.5 The submitOnChange fork — do not skip it
/send fires for button clicks and for every element with submitOnChange: true.
body.buttonId is the triggering element's id in both cases, and buttonData is {} for
buttons and for radio / check / toggle / edit — only select populates it.
So: dispatch on body.buttonId, and enumerate every submitOnChange field id, including
the last one in a chain (give it a no-op ack branch). Any id you don't route falls through
to the submit path and the app "jumps a step". Do not dispatch on buttonData.action.
Read a changed value from body.data.<fieldId>.
Two structural defences, so this stops depending on a complete list. Enumerating ids is necessary but it is a list a human has to keep in sync, which is exactly the kind of thing that rots:
- Prefer zero
submitOnChangefields when the UX allows it. A field only needs it to drive other fields on the same page (a cascade, a reveal). If the page has no cascade, leaving it off removes the whole failure class rather than handling it. The reference nine-page app shipped with none. - Make the dispatch's default branch a no-op ack, never a submit. Route it into the navigation
lookup Code node (§4.1a of
app-generation.md): abuttonIdthat matches no nav target and no button setschanges: []and returns200. Then an id you forgot to enumerate acknowledges harmlessly instead of navigating — verified live by posting an unrouted id and getting{"code":200,"data":{"changes":[],"notifications":[]}}.
7.6 Outcome → UI mapping
| Domain reply | Middleware response |
|---|---|
success | code:200, data:{changes:[…], notifications:[…]} |
alternate (registration, recovery, …) | code:302, data:{nextPage:"<page>"} |
error | code:200 with an error notification, or 4xx for a hard failure |
7.7 Invariants your brief must state
corezoid:corezoid-create runs layout-process → lint-process (until clean) →
push-process itself. What it cannot infer, and your brief must therefore spell out:
- Every fallible node (
api,api_rpc,api_code,set_param) needs its own error target — never one shared between two nodes. The target'sobj_typedepends on whether the error path does anything:- it has logic (build a fallback viewModel, then rejoin the flow) →
obj_type: 3escalation, whosegoleads on and whose ownerr_node_idends at a dedicatedobj_type: 2Error final; - it has no logic (just stop) → point
err_node_idstraight at anobj_type: 2Error final. Do not wrap it in an escalation node that only holds ago—lint-processflags that as a passthrough escalation, and padding it with a throwawayset_paramto "give it logic" earns youUNUSED SET_PARAMplusSHARED ERROR CLUSTERSinstead.
- it has logic (build a fallback viewModel, then rejoin the flow) →
- Every
api_rpcneeds atimesemaphor, or a hung callee hangs the page. 30 sec is the server minimum — lint rejects anything lower (below the server minimum of 30 sec; the deploy is rejected), so 30 s is also the floor on how fast a page can fail. Design the empty state to read sensibly after a half-minute pause, not as a flicker. - Both callback nodes need
customize_response: false,extra_type.code: "number", and the five canonical fields of §7.2 (format,send_sys,debug_info,cert_pem,max_threads). - Every
api_rpcis followed by the namespacingset_paramof §7.3 — when the task makes more than one domain call. See §7.3 for when it is pure overhead, and for why the node must be anapi_code(not aset_param) whenever a Code node is what reads the values.
After the skill reports success, record the returned numeric process id — it becomes
procId in §8.
7.8 Brief template
Fill this in and pass it verbatim as args. Reference §2.x fragments of
simulator-smart-forms-logic rather than restating their JSON, but write out every Code
Node body in full — do not leave it to chance.
Process purpose: Middleware bound to Smart Form
<ref>. Receives/getand/sendfrom the Smart Form runtime and bridges them to the domain processes<ids>viaapi_rpc.Input parameters:
path(/get|/send),body(body.page,body.buttonId,body.buttonData,body.data.*,body.context.*),sessionData,__callback_url.Expected output: POST to
{{__callback_url}}—/get→{code:200, viewModel:{…}};/send→{code:200, data:{changes,notifications}}or{code:302, data:{nextPage}}.Process type: Business logic (orchestrator).
Folder path:
<folder>Process name:<app> MiddlewareAlias: none — the bound process is referenced by numeric
procId. (Aliases are needed only for sub-processes you fan out to.)Pages:
<page ids>Buttons:<button ids>submitOnChange field ids:<ids>← every one of these needs its own dispatch armDomain processes to call: for each —
conv_id, theextra/extra_typemap built from its inputs (§3.6), and its success / alternate / error outcomes (§3.3).Required nodes: the node table of §4.1 in
$CLAUDE_PLUGIN_ROOT/docs/user-flows/app-generation.md— the spine once, then the GET branch template instantiated once per page and the SEND branch template once per button /submitOnChangeid, each with its own callback node and its own error terminals.Language of every string literal below: <single language, or driven by
body.context.language— see §6; these literals are NOT localised>Code Node bodies: <written out in full — viewModel builders, changes/notifications builders, array→table transforms per §4.3 of that doc>
Variables to create: <any constant that should land in
_ENV_VARS_.json>
8. Phase 6 — Wire the middleware to the form
The Smart Form runtime calls Corezoid as an API key. That key needs at least create
privilege on the bound process, or every /get and /send fails at the edge before reaching
node 1.
create-api-key(title="Smart Form <ref>") // → obj_id; creds in ~/.corezoid/api-keys/ … or find-principal(name="<apiLogin>", kind="api_key") for an existing key share-object(obj="conv", obj_id=<middlewareProcId>, obj_to="user", obj_to_id=<apiKeyObjId>, privs="create")
API keys are addressed as obj_to="user" — that is how Corezoid models them.
Then bind both environments — each env has its own independent binding, and a release
ships files only, never credentials. Binding only develop leaves production 5xx-ing:
updateSmartFormEnv(actorId="…", env="develop", apiLogin=…, apiSecret=…, procId="<middlewareProcId>", companyId="<companyId>") updateSmartFormEnv(actorId="…", env="production", apiLogin=…, apiSecret=…, procId="<middlewareProcId>", companyId="<companyId>")
This is why §6 created the form without credentials: the form must exist before the middleware can be designed against its pages, and the middleware must exist before its
procIdcan be bound. Create form → build middleware → bind.
9. Phase 7 — Test end-to-end, and repair
Three machine layers. Run them in order; each is cheap relative to the next. The human visual pass is a fourth layer and it lives in §10.2, after the theme lands — there is nothing worth reviewing against the design brief while the app is still unbranded.
9.1 L1 — Static
-
lint-process(process_path=<middleware>)→ must be clean. -
pushSmartForm(actorId)→ must report novalidationErrors, and read itswarnings. The push runs two passes: the per-file component-schema check, and a cross-file token audit (cduschema.ValidateTree) that the per-file pass structurally cannot do — it resolves every[[key]]against the merged app+page locale and every{{key}}against the viewModel defaults. The split follows what the runtime can still rescue:- a missing locale key is an error — locale comes only from the static files, so an
unresolved
[[key]]always reaches the browser as literal text; - a missing viewModel default is a warning — the bound process may fill it per request;
it only shows as a literal
{{key}}when the backend call fails.
Warnings do not block the push, so they are easy to skip past. Read them: an undefaulted key is precisely the L3 failure you would otherwise find by hand.
- a missing locale key is an error — locale comes only from the static files, so an
unresolved
-
Compile the Less before or with the push — a compile error never surfaces as an error (
/* Less Error … */comment, page renders unstyled). Recipe insimulator-styles→ "Verifying a styling result".
9.2 L2 — Backend, via run-task
Run synthetic tasks straight at the middleware: one /get per page, and one /send per
button and per submitOnChange field.
jsonc// /get {"path":"/get","body":{"page":"index","context":{"appId":"<id>"},"query":{}},"sessionData":{}} // /send — button {"path":"/send","body":{"page":"index","buttonId":"login_btn","buttonData":{}, "formId":"login","sectionId":"body", "data":{"phone":"380000000000","cardPassword":"0000","__token":""}},"sessionData":{}}
Expect the final callback node to fail here, by design. A synthetic task has no real
{{__callback_url}}, so the callbackapinode errors. That is not a test failure. Assert instead that the task reached the callback node with a correctly builtviewModel/responseDatain its data — inspect withlist-task-history. Treat the callback POST error as expected and say so in the report, so a reader doesn't mistake it for a real defect.
What to assert: the task routed to the right branch for the given path/page/buttonId;
the namespaced keys from §7.3 are populated; viewModel contains every key the page's config
references.
9.3 L3 — Real end-to-end
Now drive the live develop environment the way a user would (see
simulator-smart-forms-runtime):
appGetPage(accId, ref, "develop", <page>)for every page.- Assert no unresolved
{{or[[remains —{{catches a missing viewModel key, a failed backend call and a typo'd placeholder;[[catches a locale key absent for the active language, which is a different defect with the same symptom. L1's token audit should have caught both statically, so anything surfacing here means the page and the tree disagree. - Assert no value resolved to
""on alabelorimage— the renderer rejects an empty value, and an empty string contains no{{, so the check above sails past it. - Assert the items the design promised are present and
requiredwhere specified.
- Assert no unresolved
⚠️
appGetPagedoes not run the client validator. It returns the server-resolved config, so it proves templating and the backend, not validity. No component schema setsadditionalProperties: false, so an unknown key (head[].id,image.extra.height, a typo'dvisibilty) is stored, served and reported clean here — then rejected in the browser console. The real structural gate ispushSmartForm'svalidationErrors; treat a clean push as the precondition andappGetPage/appSendFormas the behavioural check on top. Plan on one human browser pass too: nothing else exercises the renderer, and CSS and BBCode are never compiled by either tool.
appSendForm(...)for every button, with representative data.- Assert the returned
code(200 / 205 / 302), and thatchanges[]ids /nextPagematch the design.
- Assert the returned
- Walk the full navigation graph, including the alternate-outcome pages (
registration,recovery) — those are the ones a happy-path-only test misses.
9.4 The repair loop
For each defect, classify it and fix it in the owning layer:
| Symptom | Layer at fault |
|---|---|
Literal {{key}} in the rendered page | viewModel builder (middleware) or missing default (develop/viewModel) |
| A field change navigates instead of updating | missing submitOnChange id in the buttonId dispatch (§7.5) |
| Wrong branch taken after a domain call | shared result/code not namespaced (§7.3) |
api_wrong_convert_param on the callback | customize_response not false (§7.2) |
| Page renders but the button does nothing | changes[].id doesn't match a real item id |
| Backend returns a shape the page can't bind | contract misread — go back to §3 |
Fix middleware defects by handing an edit brief to
Skill(skill="corezoid:corezoid-edit", args="…") — process identifier, the user-visible
change, and the exact node shapes to add/modify. Never patch the pushed .conv.json by
hand. Page-config defects you fix directly, then pushSmartForm.
Re-run from L1 after every fix. Cap the loop (about three passes per defect). If it is not converging, stop and report precisely what fails, what you tried, and what you think the cause is. A truthful "these 9 of 11 flows pass, this one doesn't and here's why" is worth far more than a loop that quietly gives up.
10. Phase 8 — Brand the theme
The mandatory resets, app shell and visually_hidden utility already shipped in §6.1 — the app
is not broken at this point, just unbranded. This phase makes it look designed.
Hand simulator-styles a brief covering:
styles/colors_fonts— brand/ink/surface/status tokens, radii, a spacing scale, the font;styles/init_styles— one base look per component (buttons by.button__<type>, inputs via.<sc> .field, tables via.<sc> td.table-cell, toasts via[class*="notifyItem"]);pages/<id>/style— per-page exceptions only (auto-appended after the main sheet, and it inherits the tokens with no@import).
Two cascade facts to put in the brief, because they decide whether overrides land at all:
- Your rules are auto-wrapped in
.cdu-page, so a bare single-class rule is already0,2,0and beats a bare renderer default for free — but the renderer theme-scopes roughly a third of its rules to the same0,2,0and loads after you, so those ties go to the default. Component overrides therefore carry!importantdeliberately; that is normal here, not a smell. - Pick class chains over ids: a
styleClassthat must beat.button.button__text(0,3,0) has to be written as e.g..appbar .button.navlink(0,4,0), not as a bare.navlink.
Verify the Less actually compiles before you push. A compile error is not an error at serve
time — it is emitted as a /* Less Error … */ comment and the page silently renders unstyled, so
neither pushSmartForm nor appGetPage will tell you. Compile locally against the same
.cdu-page { … } wrapper the server applies — the exact recipe (and the three traps that make the
obvious command fail: partials have no .less extension, lessc can't read <(…), and the npm
package is less not lessc) is in simulator-styles → "Verifying a styling result".
@font-face and @media correctly bubble out of the wrapper — verified — so they are safe to keep
in the imported partials.
Re-run L1 afterwards — styles don't change page config, but the push must stay clean.
10.1 The quality bar — what "designed" has to mean here
Hand these to simulator-styles as acceptance criteria, not as taste. Every one is checkable, and
every one is something a generated app gets wrong unless it was asked not to:
- One hierarchy per page. Page title → card titles → body → meta, using the type scale from §5.3a. If two things carry the same size and weight, the user reads neither of them first.
- The spacing and type scales, everywhere. No one-off
padding: 13pxbecause a card looked tight. - Four states on every interactive component — rest, hover,
:focus-visible, disabled/loading. A re-skin that sets onlybackgroundandborderdrops the browser's default focus outline and the app becomes keyboard-unusable; re-state it explicitly (outline: 2px solid @brand; outline-offset: 2px). - Contrast ≥ 4.5:1 for body text, ≥ 3:1 for large text and for control borders — computed from the tokens, not eyeballed. Grey placeholder text on white is the usual casualty.
- Tap targets ≥ 44px. The starter-kit button in
simulator-styles(padding: 14px 20px,font-size: 15px,line-height: 1) computes to ≈43px, so setmin-height: 44pxinstead of trusting the padding to get you there. - Tables become cards under the mobile breakpoint (
thead {display:none}+tbody {display:flex; flex-direction:column}— the recipe is insimulator-styles→ Pattern catalogue). Otherwise a phone user gets a horizontally scrolling grid. - Every image has a chosen placeholder. §6 rejects an empty
srcanddata:URIs, so the empty state of an image is a real fetchable URL you picked — not whatever the backend omitted. - Transitions stay in the 150–300 ms band the starter kit uses, and only on hover/press. No on-load animation: on a page whose first paint can legitimately be 30 s away (§7.7) it reads as jank, not polish.
- Nothing user-visible hardcoded past the locale layer (§6). A page with a branded card sitting above a Code-node string in the wrong language is not a designed page.
10.2 L4 — one human visual pass
No automated layer in this pipeline has seen the app. pushSmartForm validates JSON,
appGetPage returns server-resolved config, and neither compiles Less nor expands BBCode (§9.3).
So before you call the app done, open the develop environment in a browser — the user's, or
drive it yourself if a browser tool is available — and check the render against the brief:
- every page at the phone width first, then the other two breakpoints of §5.3a;
- the four states of §10.1 on one button and one input — tab to them: is the focus ring visible?
- the empty and error states, not just the happy path — they are what the app shows on the day the backend is down;
- the DevTools console: an unknown config key is stored, served clean and only rejected in the browser (§9.3), so this is the only place it surfaces;
- that the stylesheet applied at all — a
/* Less Error … */comment renders the page unstyled and nothing else reports it.
Fix in the owning layer (§9.4), then re-run L1. Report what you could not check — "I never rendered this in a browser" is a finding, not a footnote.
11. Phase 9 — Deploy and report
deploySmartForm(actorId) only after the user confirms — it publishes to production.
Final report:
- Smart Form
ref+actorId, and a link to open it. - Middleware process id (and any sub-process aliases).
- The coverage table — every input process and where it ended up.
- Test results per layer, including anything that is expected-to-fail (§9.2).
- The visual pass (§10.2): which pages were rendered, at which widths, by whom — and which were not looked at by anyone.
- Known gaps: unknown array shapes, unprobed processes, anything the user chose to drop, and any quality-bar item (§10.1) you knowingly left unmet.
12. Rules and pitfalls
- Never hand-author the middleware
.conv.json. Produce a brief and hand it tocorezoid:corezoid-create(orcorezoid:corezoid-editfor changes). That skill ownscreate-process→layout-process→lint-process→push-process. - Derive outputs from
api_rpc_reply, not fromparams. Declared params drift. - Alternate outcomes are features, not errors.
402/registrationand401/recoveryare whole user journeys; a 200-only reading loses them. - Use every process, or say which one you didn't and why. Silent omission is the failure mode that looks like success.
- Namespace every
api_rpcresult before the next call (§7.3). - Enumerate every
submitOnChangeid in the dispatch (§7.5). customize_response:falseon both callback nodes;extra_type.codeis"number".- Bind both envs; releases never carry credentials.
- Share the bound process to the API key, or everything fails before node 1.
- Never probe a process with side effects without explicit user consent (§4) — and never
wire a
likely/unknownone to a page's/get, which probes it on every view (§5.1a). - Never put secrets in hidden carriers or in the
302 query— carriers reach the browser and the query is the URL (history,Referer, logs, shared links). Park the session in a state process / actor and carry an opaque id; a bearer token goes in a URL only when the backend leaves no alternative and the user has been told (§7.4). And remember a submit sends one form: a carrier only reaches the handler from the same form as the button, so read the session asbody.data.X || body.query.xrather than trusting placement (§7.4). - Edit
developonly;pullSmartFormbefore editing, always. - Don't rename pulled
.conv.jsonfiles — the<ID>_prefix is load-bearing. - Report failures honestly. If L3 doesn't go green, say exactly what fails.
- Decide the look before Phase 4. Archetypes, tokens, the spacing/type scales and the state
inventory belong in
app-plan.mdand go through the gate (§5.3a). Phase 8 can rebrand a page; it cannot re-lay-out one, because the layout is in the config you already pushed. - Something human has to look at it. No layer here compiles CSS, so a clean push plus a green L3 is fully compatible with an unstyled, unusable page. One browser pass at the phone breakpoint is part of done (§10.2), and whatever nobody looked at goes in the report.
13. When to use this skill vs. its neighbours
| The user wants… | Skill |
|---|---|
| A whole app generated from existing processes | this skill |
| To edit a page / layout / viewModel of an existing form | simulator-smart-forms |
| To change the backend logic of an existing form | simulator-smart-forms-logic |
| To run / fill in an existing form | simulator-smart-forms-runtime |
| To restyle a form | simulator-styles |
| To author one Corezoid process, no Smart Form | corezoid:corezoid-create |
If the user asks to add one page or one button to an app this skill already built, that is
simulator-smart-forms + simulator-smart-forms-logic, not a regeneration.
14. Reference documents
| Path | When to read |
|---|---|
$CLAUDE_PLUGIN_ROOT/docs/user-flows/app-generation.md | The full contract-extraction algorithm, the annotated middleware skeleton, and the synthetic test-payload catalogue |
$CLAUDE_PLUGIN_ROOT/docs/user-flows/cdu-page-protocol.md | Component catalogue, templating, changes[] protocol, response codes |
$CLAUDE_PLUGIN_ROOT/docs/user-flows/smart-forms.md | Smart Form lifecycle, env binding, releases |
$CLAUDE_PLUGIN_ROOT/skills/simulator-smart-forms/SKILL.md | Page-config grammar and the pull/push/deploy cycle |
$CLAUDE_PLUGIN_ROOT/skills/simulator-smart-forms-logic/SKILL.md | The /get /send contract, node fragments, brief format |
$CLAUDE_PLUGIN_ROOT/skills/simulator-styles/SKILL.md | The Less/styles/ layer |
Corezoid plugin: docs/node-structures.md | Exact JSON schemas for api_rpc, api_rpc_reply, api, api_code, set_param, go_if_const |


