Smart Form Backend Logic
You orchestrate the Corezoid process(es) that make a Simulator.Company Smart
Form interactive. Layout, i18n, styles, and viewModel defaults live in the Smart
Form actor itself (see the simulator-smart-forms skill). Everything dynamic
— initial viewModel, page transitions, submit handling, notifications, write-backs
to actors — runs in Corezoid.
This skill does not author processes itself. Instead it:
- Confirms the Corezoid plugin is installed and its skills are available.
- Translates the user's intent into precise briefs in the exact format
corezoid-create/corezoid-editexpect (purpose · inputs · expected output · process type · concrete node skeleton). - Invokes the matching Corezoid skill with that brief and lets it run
create-process/push-process/lint-process/run-task. - Provisions an API key (
create-api-keyfor a new one, orfind-principalfor an existingapiLogin) and shares the bound process to it (share-object) so the Smart Form runtime can call it. - Finally binds the process to the Smart Form env (
createSmartFormorupdateSmartFormEnv) via thesimulatorMCP server.
0. Preflight — confirm the Corezoid plugin is installed
Before producing any brief, verify the Corezoid plugin is available in this session:
Check whether the
create-process,push-process,create-alias,create-api-key,find-principal, andshare-objectMCP tools are listed in the tool registry. If they are, thecorezoidMCP server is running. (create-api-key/find-principal/share-objectare needed by Step E below — provisioning the API key the Smart Form runtime authenticates with.)Check whether the
corezoid-create,corezoid-edit, and (optionally)corezoid-accessskills are reachable (they appear in the available-skills list when installed).If either is missing, stop and instruct the user (reply in the user's language):
"This workflow needs the Corezoid plugin. Install it from
github.com/corezoid/corezoid-ai-pluginand run/corezoid-initto authenticate, then ask me to continue."
Do not attempt to author .conv.json files yourself or call PAPI directly when
the Corezoid plugin is absent.
1. The runtime contract
The platform binds one Corezoid process per env to a Smart Form via the env's
procId. That bound process receives a POST whenever a page is rendered or a
form submission event fires. It must respond to {{__callback_url}} with the right shape:
path |
Trigger | Required response shape |
|---|---|---|
/get |
A page is being opened or re-rendered | { "code": 200, "viewModel": { … } } |
/send |
A button clicked or any element with submitOnChange: true changed |
{ "code": 200, "data": { "changes": [], "notifications": [] } } |
Two distinct /send triggers — always distinguish them:
| Source | body.buttonId |
body.buttonData |
|---|---|---|
| Button click | button's id |
{} (empty object) |
submitOnChange element |
element's id |
select → { action, value }; radio/check/most → {} (read the value from body.data.<id>) |
When an element has submitOnChange: true (e.g. a select, radio, or checkbox),
the platform fires /send immediately on value change without waiting for a
button. body.buttonId is the element's own id, and body.data carries the full
current snapshot of all field values — read the changed value from there.
body.buttonData holds { action, value } only for select (and a few components);
radio, check, and most fields send {}.
Other status codes: 205 re-render whole page; 302 redirect to another page
(data.nextPage); 4xx/5xx surfaces an error toast.
Topology is up to the developer. The bound process may handle both paths itself in a single graph, or fan out to sub-processes (one per
path/page/buttonId) viaapi_copy. The contract is the response shape, not the layout. Ask the user which approach fits the form's complexity before generating briefs.
Sample /get request (incoming)
{
"__callback_url": "https://cb-apigw.corezoid.com/callback/sync_api/…",
"body": {
"context": { "appId": "<smartFormActorId>", "rootActorId": "<actorId>",
"browser": "Chrome", "language": "en", "timeZoneOffset": -180 },
"page": "index",
"query": {}
},
"path": "/get",
"sessionData": { "userInfo": { "id": 52731, "login": "user@x.com", "saId": 5501,
"memberGroups": [79693] } }
}
Sample /send request — button click
{
"__callback_url": "https://cb-apigw.corezoid.com/callback/sync_api/…",
"body": {
"buttonId": "submit_btn",
"buttonData": {},
"context": { "actorId": "<actorId>", "rootActorId": "<actorId>", "appId": "<smartFormActorId>" },
"data": { "day_comment": "…", "self_score": "4" },
"formId": "<formId>",
"page": "index",
"sectionId": "<sectionId>",
"query": {}
},
"path": "/send",
"sessionData": { /* same as /get */ }
}
Sample /send request — submitOnChange element
This example is a select, which populates buttonData (action + value); a radio/check
change sends buttonData:{} — read the value from body.data. buttonId is the field's id, not a button.
{
"__callback_url": "https://cb-apigw.corezoid.com/callback/sync_api/…",
"__headers": {},
"body": {
"buttonId": "project_name",
"buttonData": {
"action": "select",
"value": "energy_efficiency"
},
"context": {
"appId": "0118a16b-bf08-4e13-b3e7-0f97dfa8b6db",
"browser": "Chrome",
"language": "en",
"timeZoneOffset": -180
},
"data": {
"grant_category": "ecology",
"project_desc": "",
"project_name": "energy_efficiency"
},
"formId": "project_section",
"page": "index",
"sectionId": "project_body",
"query": {}
},
"path": "/send",
"sessionData": {}
}
Sample /get callback (outgoing)
{
"code": 200,
"viewModel": {
"userName": "Alice",
"submit_btn_visibility": "visible"
}
}
Sample /send callback
{
"code": 200,
"data": {
"changes": [
{ "id": "submit_btn", "visibility": "hidden" },
{ "id": "day_comment", "visibility": "hidden" }
],
"notifications": [
{ "title": "Thank you, we received your answer", "type": "success" }
]
}
}
changes[] is a surgical patch — only listed component ids are touched.
changeRules (e.g. { "options": { "action": "replace" } }) controls how arrays
merge. Full reference: $CLAUDE_PLUGIN_ROOT/docs/user-flows/cdu-page-protocol.md
(see esp. §12 Rendering gotchas — row never wraps, radio/select option dots are JSS-hashed
<i> elements restylable only via internal-class selectors (buttons are version-proof for card
pickers), hidden-but-visibility:visible carriers still submit, and every submitOnChange id must
be handled or it falls through to your submit path).
Realistic viewModel shape
Every key returned in viewModel must match a {{placeholder}} somewhere in
pages/<page>/config or in viewModel defaults. A representative payload:
{
"userName": "Mykhailo Sydoreiko",
"metric_total_value": "245 min",
"metric_total_sub": "4.08 hr · daily activity",
"items_table_body": [
{ "value": "row_1",
"options": [
{ "title": "[url=https://…]Some entry[/url]", "value": "item_name" },
{ "title": "16min", "value": "item_duration" }
] }
],
"submit_btn_visibility": "hidden",
"self_score_visibility": "disabled"
}
Adding a new metric to the page? Add the matching key here and the matching
{{key}} in pages/<page>/config.
2. Reusable node fragments
These are the contract-bound JSON shapes any brief should embed verbatim. They are reusable building blocks — you decide how they compose into a single process or several.
2.1 Condition on path (dispatch /get vs /send)
{
"type": "go_if_const",
"to_node_id": "<getBranchNodeId>",
"conditions": [
{ "param": "path", "const": "/get", "fun": "eq", "cast": "string" }
]
}
A second go_if_const does the same for /send; the trailing go falls
through to a default branch.
2.2 Condition on body.page (dispatch per-page)
For a multi-page form, branch on body.page after dispatching by path:
{
"type": "go_if_const",
"to_node_id": "<indexBranchNodeId>",
"conditions": [
{ "param": "body.page", "const": "index", "fun": "eq", "cast": "string" }
]
}
2.3 Condition on body.buttonId (dispatch per-event source for /send)
body.buttonId identifies the source of every /send event — both button clicks
and submitOnChange field changes. Use go_if_const on it to route each case:
{
"type": "go_if_const",
"to_node_id": "<submitBranchNodeId>",
"conditions": [
{ "param": "body.buttonId", "const": "submit_btn", "fun": "eq", "cast": "string" }
]
}
2.3a Detect submitOnChange vs button click
When the same /send handler must behave differently depending on whether the
user clicked a button or changed a submitOnChange field, branch on
body.buttonId — a submitOnChange event carries the changed field's own
id, so match it against the ids of the fields you set submitOnChange:true:
{
"type": "go_if_const",
"to_node_id": "<fieldChangeBranchNodeId>",
"conditions": [
{ "param": "body.buttonId", "const": "project_name", "fun": "eq", "cast": "string" }
]
}
Do not detect field changes with body.buttonData.action != "". Only select
sends a non-empty action ("select"/"filter"); radio, check, toggle,
edit, … send no buttonData — identical to a real button click — so an
action-based guard misses them and their change falls through to your submit
path (the wizard "jumps a step"). Treat action as a secondary signal only
(e.g. to tell a select's select vs filter), never the discriminator.
For a select you can match id and action together:
{
"type": "go_if_const",
"to_node_id": "<projectFilteredNodeId>",
"conditions": [
{ "param": "body.buttonId", "const": "project", "fun": "eq", "cast": "string" },
{ "param": "body.buttonData.action", "const": "filter", "fun": "eq", "cast": "string" }
]
}
Gotcha — cover EVERY
submitOnChangefield id, or it navigates. A field change reaches/sendexactly like a button submit (with the same emptybuttonDataforradio/check/toggle/…). If yourbuttonIddispatch does not route a given change-event id into a field-change branch, it falls through to the normal submit path (page nav / actor write). Keep the id list exhaustive — including the LAST item of a progressive chain that has nothing left to reveal (give it a no-op onChange branch). Don't lean onbody.buttonData.actionto catch them: it's empty for every field exceptselect. This is the most common cause of a wizard "jumping a step" when the user just tweaked a field.
Read the new value from body.data.<fieldId> — that is the reliable source for every component.
body.buttonData.value is populated only by select (and a few components); radio, edit,
check, toggle, etc. send no buttonData, so buttonData.value is undefined for them — do not
rely on it for a radio submitOnChange.
2.4 Fan-out to a sub-process (api_copy, fire-and-forget)
Use when you want the bound process to delegate work to a separate Corezoid process. The sub-process becomes responsible for the callback.
{
"type": "api_copy",
"user_id": <yourUserId>,
"conv_id": "@<aliased-sub-process>",
"ref": "",
"mode": "create",
"group": "all",
"data": {},
"data_type": {},
"err_node_id": "<errorNodeId>"
}
2.5 Build viewModel (Code Node, api_code)
JavaScript pattern for assembling viewModel from an actor lookup or other
sources:
const viewModel = data?.viewModel || {};
const eventData = data?.event_actor?.data || {};
viewModel.userName = eventData.user_name || "";
viewModel.metric_total_value = `${eventData.total_min ?? 0} min`;
if (eventData.status === "complete") {
viewModel.submit_btn_visibility = "hidden";
viewModel.self_score_visibility = "disabled";
} else {
viewModel.submit_btn_visibility = "visible";
viewModel.self_score_visibility = "visible";
}
data.viewModel = viewModel;
2.6 Build changes + notifications (Code Node)
data.changes.push(
{ "id": "submit_btn", "visibility": "hidden" },
{ "id": "day_comment", "visibility": "hidden" }
);
data.notifications.push({
"title": "Thank you, we received your answer",
"type": "success"
});
data.responseData = { changes: data.changes, notifications: data.notifications };
2.7 Final callback API node (/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" },
"customize_response": false,
"err_node_id": "<errorNodeId>",
"version": 2
}
2.8 Final callback API node (/send)
Identical shape, with data in place of viewModel:
{
"type": "api",
"method": "POST",
"url": "{{__callback_url}}",
"rfc_format": true,
"content_type": "application/json",
"extra": { "code": "200", "data": "{{responseData}}" },
"extra_type": { "code": "number", "data": "object" },
"extra_headers": { "content-type": "application/json; charset=utf-8" },
"customize_response": false,
"err_node_id": "<errorNodeId>",
"version": 2
}
2.9 Complete process skeleton (bound, single-process topology)
The fragments above (§2.1–2.8) are pieces; this is how they wire together in
the most common topology — a single bound process that owns both /get and
/send. Use this as the default starting shape and only depart from it when the
form's complexity demands a multi-process layout (see §3).
Why this matters. The single most common failure when writing a Smart-Form backend is forgetting that
/sendhas two sources — a button click and anysubmitOnChangefield change — and processing both as a real submit. The form then tries to persist a half-filled record on every field interaction. The skeleton below makes that second fork explicit.
2.9.1 Flowchart
┌───────┐
│ Start │
└───┬───┘
│
┌───────────▼───────────┐
│ Condition on `path` │ (§2.1)
└─┬───────────────────┬─┘
│ │
path=/get path=/send
│ │
┌──────────▼─────────┐ ┌──────▼──────────────────────┐
│ Build viewModel │ │ Extract │
│ (api_code) §2.5 │ │ data.buttonId = │
└──────────┬─────────┘ │ body.buttonId │
│ │ (api_code) │
│ └──────┬──────────────────────┘
│ │
│ ┌───────────▼───────────────────┐
│ │ Condition on buttonId │ (§2.3a)
│ │ submit_btn ─→ real submit │
│ │ project_name ─→ onChange │
│ │ up_file… ─→ onChange │
│ │ … │
│ └─┬───────────────────────┬─────┘
│ │ │
│ real submit submitOnChange
│ (buttonId = (lightweight
│ submit_btn, ack: empty
│ dispatch §2.3) changes[])
│ │ │
│ ┌─────▼────────┐ ┌──────▼─────────┐
│ │ Persist / │ │ Build empty │
│ │ call CREATE │ │ sendResponse │
│ │ ACTOR / api │ │ Data │
│ │ (api_rpc / │ │ (api_code §2.6)│
│ │ api §2.6) │ └──────┬─────────┘
│ └──────┬───────┘ │
│ │ ok │
│ ┌──────▼─────────┐ │
│ │ Build success │ │
│ │ sendResponse │ │
│ │ Data (api_code)│ │
│ └──────┬─────────┘ │
│ │ │
│ │ ┌── on err ────────┤
│ │ │ │
│ ┌──────▼───▼─────┐ │
│ │ Build error │ │
│ │ sendResponse │ │
│ │ (notification) │ │
│ └──────┬─────────┘ │
│ │ │
┌──────────▼───┐ ┌──▼──────────────────────▼──┐
│ Callback │ │ Callback to │
│ to │ │ {{__callback_url}} │
│ {{__callback_│ │ POST { code:200, │
│ url}} │ │ data:{ │
│ POST { code: │ │ changes, │
│ 200, │ │ notifications │
│ viewModel} │ │ } } §2.8 │
│ §2.7 │ └────────────┬───────────────┘
└────────┬─────┘ │
└─────────┬────────────┘
│
┌────▼────┐
│ Success │
└─────────┘
2.9.2 Node sequence
| # | Node title | obj_type / logic | Routes to (success / err) | See |
|---|---|---|---|---|
| 1 | Start | 1 / go |
→ 2 | — |
| 2 | Dispatch by path |
0 / go_if_const |
/get→3, /send→4, default→Error |
§2.1 |
| 3 | Build viewModel (GET branch) | 0 / api_code |
→ 8 (Callback GET), err→Error | §2.5 |
| 4 | Extract body.buttonId |
0 / api_code |
→ 5, err→Error | §2.5 |
| 5 | Dispatch by buttonId |
0 / go_if_const |
button ids→6 (real submit), submitOnChange field ids→7 (ack) |
§2.3a |
| 6 | Persist / call CREATE ACTOR / … | 0 / api_rpc or api |
→ 6a (success), err→6b (error) | §2.4 / §2.6 |
| 6a | Build success sendResponseData |
0 / api_code |
→ 9 (Callback SEND) | §2.6 |
| 6b | Build error sendResponseData |
0 / api_code |
→ 9 (Callback SEND) | §2.6 |
| 7 | Build empty-ack sendResponseData |
0 / api_code |
→ 9 (Callback SEND) | §2.6 |
| 8 | Callback GET (viewModel) | 0 / api |
→ Success, err→Error | §2.7 |
| 9 | Callback SEND (data) | 0 / api |
→ Success, err→Error | §2.8 |
| 10 | Success | 2 (terminal) | — | — |
| 11 | Error | 2 (terminal) | — | — |
2.9.3 Key invariants
- Two forks in
/send, not one. Fork-1 (path) separates GET from SEND. Fork-2 (buttonId) separates a real submit from asubmitOnChangeevent. Skipping fork-2 is the most common Smart-Form-backend bug: the platform fires/sendevery time a select/check/radio withsubmitOnChange:truechanges value, and the process will try to persist a half-filled form. buttonIddispatch rule of thumb.body.buttonIdmatching one of your button ids ⇔ real button click — pick the persistence action (§2.3). Matching asubmitOnChangefield id ⇔ field change — usually return an emptychanges:[]ack, or a targeted cascade update if the change should drive other fields. Do not dispatch onbody.buttonData.action: it is non-empty only forselect("select"/"filter");radio,check,toggle,edit, … send emptybuttonData, so an action-based fork routes them to the real-submit branch.- Single shared callback per path. All
/sendbranches (success, error,submitOnChangeack) converge on oneCallback SENDnode — they differ only in what they pre-fill intodata.sendResponseData. Same for/get: oneCallback GETreachable from every viewModel-building branch. - Response shape is decided by the path branch.
/get→{ code: 200, viewModel: {…} }./send(every subtype) →{ code: 200, data: { changes, notifications } }. Mixing them silently breaks the form — the platform either ignores the payload or shows an error toast.
2.9.4 When to expand this skeleton
- Multi-page form. Insert a third fork on
body.pagebetween the path dispatch and the GET/SEND handlers — one viewModel builder per page, one action dispatcher per page (§2.2). - Multiple submit buttons on one page. Inside the "real submit" branch
(node #6), fork on
body.buttonId— one persistence path per button (§2.3). - Multiple
submitOnChangefields with distinct logic. Replace node #7's single ack with abuttonIdfork — one cascade-update branch per field. Read the new value frombody.data.<fieldId>(§2.3a). - Heavy logic on either side. Split the persistence node (#6) — or the whole
GET branch — into a separate aliased sub-process called via
api_copy. See §3 for the multi-process topology.
3. Creating logic from scratch — brief handoff to corezoid-create
Step A — agree on topology with the user
Ask (or infer from form complexity): does the user want one process handling
everything, or multiple processes fanned out via api_copy? Common shapes:
- Single bound process — Start → Condition on
path→ /get branch (build viewModel inline → callback API) and /send branch (Condition onbuttonId→ Code Node → callback API). Best for small forms with one page and one button. Remember: the /send branch must handle both button clicks andsubmitOnChangeelements — branch onbody.buttonIdfor each. - Bound process + per-path sub-processes — bound process dispatches
/getand/sendto two aliased sub-processes viaapi_copy; each sub-process owns its own callback. Best when /get and /send logic differs sharply. - Bound process + per-page-per-path sub-processes — adds a
body.pagedispatch. Best for multi-page forms.
When designing the /send topology, always ask: which elements on each page
have submitOnChange: true? Each such element is an independent event source
(its body.buttonId = the element's id; dispatch on that id, not on
body.buttonData.action, which is empty for everything except select).
If submitOnChange events only update UI state (cascade a select choice into
dependent fields) they usually return a lightweight changes[] with no persistence.
If they trigger saves or lookups they need their own sub-process or branch.
Whichever shape, the bound process (the one whose numeric id goes into
corezoidCredentials.procId) does not need an alias. Sub-processes called from
it via api_copy do need aliases.
Step B — pick the target folder
Ask the user (or infer from pull-folder artifacts) which Corezoid folder should
host the process(es). A common convention is <smart-form-ref>/ (a dedicated
folder per Smart Form), but it is not enforced.
Step C — generate one brief per process
Use the template below, once per process you need. Then invoke
corezoid-create once per brief — the Corezoid skill will run create-process
→ fill the JSON → lint-process → push-process.
Process purpose: <one paragraph — what task this process performs, who calls it, and what it must produce>
Input parameters: (top-level fields available on
data)
path(string) —/getor/send, only when this process is the bound onebody(object) — CDU payload; the inner fields you actually use, e.g.body.page,body.context.rootActorId,body.data.<field>,body.buttonIdsessionData(object) — only if you read userInfo / memberGroups__callback_url(string) — only if this process replies to the platformExpected output:
- HTTP POST to
{{__callback_url}}with body{ code: 200, viewModel: { … } }(see §1 for shape), OR- HTTP POST to
{{__callback_url}}with body{ code: 200, data: { changes, notifications } }, OR- No callback (fire-and-forget — for sub-processes whose caller already answered the platform), OR
- Reply to caller via
api_rpc_reply(when this process is itself called viaapi_rpcfrom another).Process type: Business logic (orchestrator) / API connector.
Folder path:
<folder>Process name:
<descriptive name>Alias to register after push:
<short-name>— required when other processes will call this one viaapi_copyorapi_rpcusing@<short-name>. Omit for the bound process (it is referenced by numericprocId).Required nodes (in order, with title + logic type):
- Start (
obj_type: 1,go)- <Set Param / Code Node / Condition / Call a Process / API node / Copy Task> — purpose. Payload shape: see §2..
- … N. Final (
obj_type: 2) + error escalation nodes for every fallible step.Specific node-by-node guidance: reference §2 fragments by number and list the exact
extrakeys / Code Node JS the brief should embed. Do not leave the Code Node body to chance — write it out inside the brief.Variables to create (if any): list every constant (URL, alias, account id) that should land in
_ENV_VARS_.jsonas{{env_var[@…]}}.
Step D — invoke corezoid-create
For each brief, call:
Skill(skill="corezoid-create", args="<the brief above, verbatim>")
If your topology includes sub-processes, after each successful push register its
alias (the corezoid-create skill calls create-alias automatically if you
include the "Alias to register after push" line, but you can also call it
explicitly):
create-alias(short_name="<short-name>", process_id=<numericProcessId>)
Make sure aliases referenced by the bound process exist before traffic flows —
otherwise api_copy to a missing alias will fail.
Step E — provision the API key that will call the bound process
The Smart Form runtime authenticates with Corezoid as an API key
(apiLogin + apiSecret). That key must have at least create privilege on
the bound process — otherwise every /get / /send fails at the platform edge
before reaching the process graph. Sub-processes called via api_copy /
api_rpc do not need to be shared to the key; the bound process runs them
under its own owner.
Ask the user which path they want:
- (A) Create a fresh API key for this Smart Form — best when there is no existing automation key for this form.
- (B) Reuse an existing API key — the user already has an
apiLogin/apiSecretpair and wants to attach the bound process to it.
Path A — create the key, then share the process to it
create-api-key(title="Smart Form <smart-form-ref>")— the Corezoid plugin returns the key's numericobj_idand writes the credentials to~/.corezoid/api-keys/<slug>-<obj_id>.json(mode 0600). Read that file to obtainapiLogin+apiSecret.share-object(obj="conv", obj_id=<boundProcessId>, obj_to="user", obj_to_id=<apiKeyObjId>, privs="create")— grants the key permission to create tasks in the bound process. API keys are addressed asobj_to="user"(not"api_key") on the share endpoint; that's how Corezoid models them internally.
Path B — find an existing key by apiLogin, then share the process to it
The user knows apiLogin + apiSecret but not the key's numeric obj_id.
find-principal(name="<apiLogin>", kind="api_key")— resolves the key by login and returns itsobj_id. If multiple keys match (or none do), surface the candidates back to the user and ask which to use — do not guess.share-object(obj="conv", obj_id=<boundProcessId>, obj_to="user", obj_to_id=<apiKeyObjId>, privs="create")— same call as Path A.
Higher-level alternative. If the Corezoid plugin's
corezoid-accessskill is available in this session, delegate the find-principal → share-object step to it instead of calling the MCP tools directly — it wraps the same flow with sensible defaults.
After this step you hold a valid apiLogin + apiSecret pair that can call
the bound process. Carry them forward to Step F.
Step F — bind the bound process to BOTH Smart Form envs
Every Smart Form has two envs (develop and production), and each one
has its own independent Corezoid binding. Setting credentials on one env does
not populate the other — without an explicit second call, production
stays empty and the live form 5xx's the moment it is opened.
Per env you need: apiLogin, apiSecret, procId (the bound process's
numeric id), and companyId (workspace identifier — a string, e.g. a UUID
"4ddb8938-65f4-4f83-8208-7ac3faffe671" or an "i…"-prefixed id like
"i12412424" — not a number; pass it quoted). Source apiLogin/apiSecret
from Step E, companyId from workspace settings.
Confirm with the user whether develop and production should hit the
same Corezoid stage (same procId + key) or different stages (separate
dev / prod processes, separate keys — each shared per Step E). Default
assumption when the user has not said otherwise: same stage for both, so the
same quadruple goes into both envs.
Option A — at Smart Form creation (preferred when the form is new).
The four flat fields are applied to both envs in one call:
createSmartForm(
title="…", ref="<smart-form-ref>",
apiLogin="…", apiSecret="…",
procId="<boundProcessId>",
companyId="<companyId>"
)
If develop and production should differ, pass the per-env shape via
corezoidCredentials (the flat fields are ignored when this is set):
createSmartForm(
title="…", ref="<smart-form-ref>",
corezoidCredentials='{
"develop": {"apiLogin":"…","apiSecret":"…","procId":"<devProcId>", "companyId":"<companyId>"},
"production": {"apiLogin":"…","apiSecret":"…","procId":"<prodProcId>", "companyId":"<companyId>"}
}'
)
Option B — update an existing Smart Form's bindings.
updateSmartFormEnv writes one env at a time. Always call it twice —
once for develop and once for production — unless the user is intentionally
updating only one side:
updateSmartFormEnv(
actorId="<actorId>",
env="develop",
apiLogin="…", apiSecret="…",
procId="<boundProcessId>",
companyId="<companyId>"
)
updateSmartFormEnv(
actorId="<actorId>",
env="production",
apiLogin="…", apiSecret="…",
procId="<boundProcessId>",
companyId="<companyId>"
)
The tool resolves the env name to its numeric id internally. Updating develop
credentials does not create a release. production credentials are
updated independently — they are never copied from develop by a regular
deploySmartForm call (releases ship files, not env credentials).
Step G — smoke-test
- Open the Smart Form in the platform UI — a
/getlands in the bound process and the page renders with the returned viewModel. - Click a submit control —
/sendlands in the bound process andchanges[]mutates the page in place; notifications appear. - Inspect failed tasks in the Corezoid UI; use
run-task(delegated throughcorezoid-create/corezoid-edit) to replay tasks with tweaked data.
4. Editing existing logic — brief handoff to corezoid-edit
Use this branch when the backend process(es) for a Smart Form already exist and the user wants to modify behaviour (add a button branch, add a page handler, extend the persisted payload, fix the viewModel builder, etc.).
Step A — locate the process
The corezoid-edit skill's MANDATORY first step is to resolve PROCESS_PATH.
Hand it the identifier directly so it does not have to ask:
- If you know the file path locally, pass it.
- Otherwise pass the process name or alias (
@<short-name>) —corezoid-editwill resolve it from the pulled tree.
If the form has multiple backing processes and you are not sure which one owns
the behaviour to change, inspect the bound process first (its numeric id is in
corezoidCredentials.procId for the relevant env) and follow api_copy edges
from there.
Step B — classify the change
For each user-visible change, decide which logical piece needs editing:
| Change | Where the change lands |
|---|---|
| New page added to the Smart Form UI | Bound process (new body.page branch) + new page handler |
| New submit button on existing page | Branch that handles /send for that page — new buttonId arm |
New {{placeholder}} in the page config |
Code Node that assembles viewModel for that page |
| Persist additional submit fields | /send branch — extend the data payload sent to @api-sim-update-actor (or your equivalent) |
Different post-submit changes[] (keep form editable, redirect, …) |
Code Node that builds data.changes / data.notifications |
| Switch the source actor for viewModel | @api-sim-get-actor-by-id (or equivalent) extra |
| Add server-side validation before save | Add Condition + Code Node + error changes[] ahead of persist |
For each landing spot, identify the specific process file (single bound process or one of the sub-processes in your topology).
Step C — produce an edit brief and invoke corezoid-edit
The corezoid-edit skill expects: process identifier, what to change, and the
exact node shapes for additions. Use this template:
Process:
<path / name / alias>Goal:
Nodes to add / remove / modify:
- : (
<obj_type>/<logic type>) — connected from<prevTitle>, error →<errNodeTitle>. Payload:{ /* fragment from §2 */ }- …
Existing nodes to keep: every node not mentioned above.
Callback contract reminder: the API node that POSTs to
{{__callback_url}}must keepextra_type.code: "number"and the viewModel/data payload typed asobject. Always setcustomize_response: false— cb-apigw acks the callback POST with an empty (non-JSON) body, so enabling response-body casting throwsapi_wrong_convert_param: "Param: body, Value: , Try convert to: object".After deploy: push the process (
corezoid-editdoes this automatically in its Step 3) and ask me to re-test in the UI.
Hand the brief over with:
Skill(skill="corezoid-edit", args="<the brief above, verbatim>")
Step D — propagate dependent edits
A change is rarely isolated:
- New
{{placeholder}}inpages/<page>/config→ matching key set in the viewModel-building Code Node. - New
buttonIdinpages/<page>/config→ new branch in the/sendhandler. - New page in
pages/<id>/config→ newbody.pagebranch in whatever process dispatches per page; possibly a new sub-process.
After editing logic, also drive simulator-smart-forms to push the matching UI
changes (pushSmartForm) so the contract stays consistent.
Step E — re-bind credentials only if procId changed
If the edit replaces the bound process itself (rare — only when its numeric id
changes), repeat Steps E (share the new process to the API key — share-object
with the new obj_id) and F (re-bind via updateSmartFormEnv) from §3.
Editing nodes inside the bound process does not change its procId and
does not require re-sharing or re-binding.
5. Conventions, pitfalls, and rules
{{__callback_url}}must be exact. It is provided by the platform on every incoming task. Never reconstruct or hardcode it.- Object payloads need
extra_type: "object".viewModelanddatain the final API node must be declared as object types so Corezoid serializes them as JSON, not stringified strings. - Set
customize_response: falseon the callback API node. cb-apigw returns an empty (non-JSON) body when it acks the callback POST. Ifcustomize_responseistrue(or left at a default that enables response-body casting), Corezoid tries to convert the empty string toobjectand throwsapi_wrong_convert_param: "Param: body, Value: , Try convert to: object"— even though the payload was already delivered successfully. The ack body is never used downstream, so always setcustomize_response: false(see §2.7, §2.8). - Numeric
code.extra.code = "200",extra_type.code = "number". The Smart Form serve layer rejects responses wherecodearrives as a string. - Every fallible node needs
err_node_id. Code Nodes, API Calls, Call a Process, Copy Task, Set Param all need an escalation target. Errors should POSTcode: 500(or 4xx for validation) back to{{__callback_url}}so the user sees a usable error notification instead of a hung page. - Match
idvalues to the page config. Everychanges[].idmust match theidof a real item inpages/<page>/config. Unknown ids are silently dropped. buttonIdis the source id for every/sendevent — not only buttons. Both button clicks andsubmitOnChangefield changes arrive as/sendwithbody.buttonIdset to the triggering element'sid. Distinguish the two bybody.buttonData: it is{}for button clicks and{ action, value }for field-change events (see §2.3a). Always check which elements on a page havesubmitOnChange: truebefore designing the/senddispatch tree.memberGroupsfor authz.sessionData.userInfo.memberGroupsis the source of truth for role-b
…(truncated)