simulator-smart-forms

Simulator.Company Smart Form (CDU / Script / Application) specialist. Use when the user wants to create, edit, inspect, or deploy a Smart Form; work with its pages, locale, viewModel, styles, definiti

SKILL.md

GitHub

Simulator.Company Smart Form Author

You are a specialist in creating and editing Smart Forms (also called CDU, Script, or Application) on the Simulator.Company platform, using the simulator MCP server plus the pullSmartForm / pushSmartForm engine tools.


Core Concepts

A Smart Form is three things in one:

Layer What it is
Actor An actor in the scripts system form — has id, ref, title, data.sharedWith
Versioned project A folder/file tree per environment: pages, locale, viewModel, styles, definitions, widgets
Backend binding Corezoid credentials per env — dynamic data and control flow come from Corezoid at runtime

A Smart Form is not a data-schema form. Compare:

Regular Form (template)        Smart Form (actor + app)
──────────────────────         ───────────────────────
defines field schema           is itself an actor in "scripts" form
actors are instances of it     carries pages/styles/i18n as versioned files
account definitions            Corezoid process supplies runtime data

Environments

Every Smart Form always has exactly two environments:

Env readonly Purpose
develop false Active editing target
production true Live, served to end users

Rule: never write directly to production. Edit develop, then deploy develop → production via a release. The server rejects writes to readonly envs.


Project File Structure

After pullSmartForm the local directory looks like:

<actorId>/
  develop/
    .manifest.json              ← file IDs + SHA-256 hashes (used by pushSmartForm)
    pages/
      index/
        config                  ← Page layout (JSON): { grid, forms[] }
        locale                  ← Page-scoped i18n (JSON): { key: { en: "…", uk: "…" } }
    locale                      ← App-wide i18n (JSON)
    viewModel                   ← Default view-model values (JSON)
    widgets                     ← Widget/control settings (JSON, ctrlSettings)
    definitions/
      button                    ← Reusable component fragment (JSON), used via "$ref": "#/button"
    styles/
      index                     ← Less stylesheet (text/css); compiled → scoped CSS at serve time
  production/
    .manifest.json
    … (same tree, read-only)

File roles

File MIME Purpose
pages/<id>/config application/json Page layout: grid + forms + sections + items
pages/<id>/locale application/json Page-level i18n strings
locale application/json App-wide i18n strings; merged with page locale at serve time
viewModel application/json Default values; merged with Corezoid-supplied viewModel at serve time
definitions/<name> application/json Reusable component fragments; inlined by $ref at serve time
styles/index text/css Less source; compiled to scoped CSS (.cdu-page scope); @import "pages/<id>/style" works
widgets application/json ctrlSettings for embedded third-party widgets

Standard Workflow

Creating a new Smart Form

createSmartForm(title="My App", ref="my-app")
→ { actorId: "...", envs: [{id: 1, title: "develop"}, {id: 2, title: "production"}] }

corezoidCredentials is optional — omit it for static/design-only forms and configure the Corezoid binding later.

Then immediately pull the default skeleton:

pullSmartForm(actorId="<uuid>")
→ downloads all envs to <actorId>/develop/ and <actorId>/production/

Edit cycle (existing or newly created form)

1.  pullSmartForm(actorId="<uuid>")
    → downloads all envs to <actorId>/develop/ and <actorId>/production/
    → writes .manifest.json (file IDs + hashes) in each env dir

2.  Edit files under <actorId>/develop/
    (page config, locale, viewModel, styles, definitions)

3.  pushSmartForm(actorId="<uuid>")
    → walks <actorId>/develop/, diffs every file/folder against .manifest.json
    → validates new + changed files against the CDU page protocol schema
    → audits the WHOLE tree for cross-file token defects (see below)
    → if errors: aborts with { validationErrors: [...] } — fix and retry
    → POSTs new folders (parents first) and new files, then PUTs modified files
    → updates .manifest.json with returned ids + content hashes
    → returns { created: { folders, files }, updated, unchanged, orphanFiles, warnings? }

4.  Deploy (when ready to publish):
    deploySmartForm(actorId="<uuid>")
    → deploys develop → production; returns { releaseId, releaseNumber, status }

The cross-file token audit (validationErrors vs warnings)

Per-file schema validation cannot see whether a [[key]] resolves or a {{key}} has a default — those need the locale and viewModel files alongside the page. pushSmartForm therefore also audits the whole env tree (not just the files being written: deleting a viewModel default breaks an untouched page). The split follows what the runtime can still rescue:

Finding Where Why
[[key]] in neither app locale nor that page's locale, and that page or one of its locale files is in this push validationErrors — push aborts locale resolves from files only; nothing at runtime can supply it, so it always reaches the browser as the literal text [[key]]
the same miss in a page nobody touched in this push warnings (tagged pre-existing) it was already live before the push; blocking would strand an unrelated fix, since pushSmartForm has no force flag
{{key}} with no viewModel default warnings the bound Corezoid process returns a per-request viewModel merged over the defaults, so it may be filled at runtime — it renders literally only when the backend call fails
a label/image whose whole value is {{key}} and whose default is "" warnings the renderer rejects an empty value outright; use a non-breaking space (label) or a fetchable placeholder URL (image — a data: URI is rejected by the image proxy)
a viewModel default no page or definition references warnings dead key, usually left behind by a removed component
a section with literal contentLoop entries missing a key its template uses warnings those rows render the literal {{key}} — checked per entry, and named

A templated contentLoop ("contentLoop": "{{promos_loop}}") is backend-filled, so the placeholders inside its content template are not viewModel keys and are never reported. Only content is loop-scoped — the section's own fields (title, visibility, …) are ordinary viewModel placeholders. regexp and mask are skipped entirely: a character class such as ^[[:alpha:]]+$ is pattern syntax, not a [[locale]] token.


Page Config Format (pages/<id>/config)

The page config is the layout template. Structure: Page → Grid → Form → Section → Item.

Minimal page

{
  "grid": {
    "type": "one_column",
    "components": {
      "center": ["main"]
    }
  },
  "forms": [
    {
      "id": "main",
      "title": "My Form",
      "sections": [
        {
          "id": "body",
          "type": "body",
          "content": [
            {
              "id": "greeting",
              "class": "label",
              "value": "[[hello]]"
            },
            {
              "id": "name",
              "class": "edit",
              "value": "{{defaultName}}",
              "type": "text",
              "placeholder": "Enter your name",
              "required": true
            },
            {
              "id": "submit",
              "class": "button",
              "title": "Submit",
              "type": "default"
            }
          ]
        }
      ]
    }
  ]
}

Page (root of config)

{
  "title": "My orders",        // browser tab title; see below
  "grid":  { /* layout */ },
  "forms": [ /* content */ ],
  "language": "uk",            // base locale for [[…]] and date formatting
  "styleClass": "orders-page",
  "query": { "ref": "{{ref}}" },
  "notifications": [ /* shown when the page opens */ ],
  "extra": { "showScrollToTop": true, "focusVisible": true }
}

title names the browser tab. Omit it and the tab is named by the host application — <page> - <script ref>, or the script ref alone on the index page. A Smart Form embedded into another screen (a modal, a section header) never renames the browser tab, whether a title is set or not.

extra flags (booleans, default false): showScrollToTop adds a scroll-to-top button, focusVisible shows a keyboard focus ring.

A whole value may be a placeholder. query, notifications and extra are resolved server-side, so any of them can be written as a single {{viewModel}} or [[locale]] token and arrive as an object or an array:

"query":         "{{query}}",      // → object
"notifications": "{{messages}}",   // → array
"extra":         "{{pageExtra}}"   // → object

The token has to be the entire string — with text around it the result is always a string. The same holds inside grid.header, where every nested key is resolved ("extra": { "steps": "{{steps}}" }).

Not everything is resolved, and a placeholder written in the wrong place is silently left as literal text: grid.type, grid.styleClass, grid.sideBar, grid.header.class and the keys inside a page-level extra are all taken as written.

Grid

{
  "type": "one_column" | "two_column",
  "header": {
    "class": "default" | "steps",
    "extra": { "steps": ["Step 1", "Step 2"], "active": 1 }
  },
  "components": {
    "header":  ["<formId>"],
    "left":    ["<formId>"],
    "center":  ["<formId>"],
    "right":   ["<formId>"],
    "footer":  ["<formId>"],
    "sidebar": ["<formId>"]
  },
  "styleClass": "custom-grid"
}

Form

{
  "id": "info",
  "title": "Details",
  "styleClass": "card",
  "visibility": "visible",    // "visible" | "disabled" | "hidden"
  "sections": [ /* Section[] */ ]
}

Section

{
  "id": "s1",
  "type": "body",             // "body" | "block" | "modal" | "float"
  "visibility": "visible",
  "header":  [ /* Item[] — Label | Button only */ ],
  "content": [ /* Item[] — the main items */ ]
  // modal-only: "modalHeader": [ /* Item[] */ ],
  //             "modalSize": "small"|"medium"|"large"|"xlarge",
  //             "modalCloseConfirmText": "…"
}

block renders as a grouped card; modal/float are overlays.

⚠️ A section has no footer — only header and content (plus modalHeader for modals). Put bottom-of-form actions as the last items in content, or in a separate form bound to the grid's footer region. (The grid's components.footer above holds form ids, not items.)


Component Catalogue

Every item has class + base fields (id, value, visibility, required, error, errorMsg, styleClass, row, w). Below are the most common components:

Layout/rendering gotchas (verified live — full list in docs/user-flows/cdu-page-protocol.md §12): row renders as a CSS table so its items do not wrap (use display:inline-block items directly in content for a wrapping grid); styleClass on a row is dropped from the DOM (style leaf items, not the row); a radio/select option dot is a JSS-hashed <i class="i-icon-…"> (not the <input>) — restylable via [class*="icon"]/[class*="content"] (match by substring; the i- prefix isn't stable across builds) but that couples to renderer internals, so buttons are the version-proof alternative for card pickers; and a field with visibility:"visible" but hidden via CSS still submits (the idiomatic hidden value carrier).

Input components

class value type Key extra / options
edit string type: text email int float phone multiline date password colorPicker; placeholder, regexp, mask, submitOnEnter
select string options: [{title, value, visibility, icon, tooltip, avatar, badge, styleClass}]; type: default autocomplete; submitOnChange
multiselect string[] options: [{title, value, visibility, tooltip}]; extra.length (max)
radio string options: [{title, value, visibility}]; extra.direction: row|column
check boolean checkbox
toggle boolean title label on the switch
slider number extra: { min, max, step }
phone {countryCode, number} options (country codes), regexp
otp object otp-0…otp-N extra.length (2–20); type: text|int

Display components

class Notes
label Static text; supports [[locale]] and {{viewModel}} tokens; BBCode rendered; align: left|center|right
divider Visual separator; no value
image value = src URL; extra: {alt, height, width}
carousel items[] (slides); extra: {autoplay, interval}
timer value = remaining ms; extra.duration
comments Comment thread widget; title

Action components

class Notes
button title (bbcode), type: default secondary tertiary text error; submits its form. extra.url opens the URL instead of submitting (extra.target _self|_blank — newer renderers only; older open same-tab); extra.action:'logout'; extra.request (bare fetch first, submits only if it resolves); extra.autoSubmit {interval,maxCount} (interval clamped 5–60s); extra.options[] (menu — bypasses url/request/action/submit); extra.icon, rounded, mobileVisible
copy value = text to copy; title = button label

Data & navigation components

value, never id, is the key in an option or column list — table.head[], tab.options[], stepper.options[]. Cells of a table row live in body[].options[], not as direct row properties. The wrong shape passes the server and fails only in the browser ("head[0].id" is not allowed); see cdu-page-protocol.md §5.1 and §10.1.

class Notes
table head: [{value,title}] — the column key is value; body: [{value: <rowId>, options: [{value: <columnKey>, title: <cellText>}]}]; type: default radio check; submitOnChange, submitOnScroll
tab options: [{value,title}]; value = selected option's value; submitOnChange
stepper options: [{value,title,completed}]; value = current option's value; extra.mobileVisible only
mainMenu Nested navigation; options tree

File components

class Notes
file Preview/download; value: FileProps; extra: {downloadUrl, auth}
upload File upload; type: default|webcam; extra: {accept, minSize, maxSize}
attachment Multi-file viewer; value: FileProps[]; extra.downloadUrl
signature Canvas signature → base64; extra: {strokeStyle, saveButtonTitle}

Layout

Do not hand-author a row component. Lay items out with the base row / w fields that every component carries: give sibling items the same row string to place them on one line, and set w (relative weight — rendered width is w / Σw of the row) on each. The renderer synthesizes a row component from them. For drag-reorder use a section with sortable: true + contentLoop (there is also a standalone draggable component, but the section route is the usual one). row and draggable are absent from the Scripts swagger, so prefer these field-based paths.

// two items on one line, 50% each (equal weights → each gets half the row):
{ "id": "first", "class": "edit", "type": "text", "row": "1", "w": "50" }
{ "id": "last",  "class": "edit", "type": "text", "row": "1", "w": "50" }

Embedded widgets

class: "widget" — type: iframe onfido twilio amazonConnect webComments. Each type has its own extra schema.


Templating

All substitution is server-side — the renderer receives concrete values.

Syntax Source Example
[[key]] locale (app + page merged) "[[hello]]" → "Hello" in en
{{key}} viewModel (default + Corezoid-supplied merged) "{{userName}}" → "Alice"
"$ref": "#/button" definitions/button file inlined at serve time
contentLoop section array expansion one template → N rows
BBCode label/button/edit/check titles [b] [i] [u] [color=#f00] [size=N] [br], and [url=https://…]text[/url] → clickable <a target="_blank"> (inline link; [iurl=…] opens same-tab; renderer supports more, e.g. [bg]). Raw <a> HTML is escaped — use [url].

⚠️ Visibility may be a view-model placeholder — but nothing else about it is reactive. Form, section, and rendered-item visibility (in header, modalHeader, and content) may be a pure view-model placeholder such as "{{graphPreviewVisibility}}"; the server resolves it to visible|disabled|hidden before the page reaches the client. Malformed or embedded forms ("state-{{x}}", "{{ x }}", a bare enum typo) are rejected at push, and footer is not server-rendered so a placeholder there is rejected too. This is initial/server-render binding, not a client-side expression: to reveal one field after another changes, use submitOnChange plus a 200 changes response.

⚠️ A label value may never be the empty string (nor an image an empty src) — the renderer rejects both. Use a non-breaking space for a blank-looking label. For an image use a fetchable http(s) placeholder URL (or an actor-attached asset): image.value is loaded through the /api/1.0/image?src= proxy, which rejects a data: URI with 400 "URL is not allowed".

locale file format

{
  "hello": { "en": "Hello", "uk": "Привіт" },
  "submit": { "en": "Submit", "uk": "Надіслати" }
}

viewModel file format

{
  "defaultName": "Anonymous",
  "maxItems": 10
}

definitions fragment format

{
  "class": "button",
  "title": "[[submit]]",
  "type": "default"
}

Used in config as { "$ref": "#/button" } — the whole object is replaced.


Change Protocol (POST 200 response)

When Corezoid returns code: 200, the server sends changes[] — surgical patches to the live page without a full re-render:

[
  {
    "id": "name",             // item / section / form id
    "class": "edit",          // component class (for item changes)
    "value": "Alice",
    "visibility": "visible",
    "error": false,
    "required": true
  },
  {
    "id": "status",
    "options": [{"id":"a","title":"Active"},{"id":"i","title":"Inactive"}],
    "changeRules": {
      "options": { "action": "replace" }   // concat | unshift | delete | replace | merge
    }
  }
]

code: 205 → re-render a whole page (can switch to a different pageId). code: 302 → redirect to nextPage.


Creating a Smart Form from Scratch

Use the createSmartForm MCP tool — it calls POST /papi/1.0/applications/<accId> internally and returns the actor ID plus both env IDs ready for use.

createSmartForm(
  title="My App",
  ref="my-app",
  description="Optional",          // optional
  sharedWith="userList",            // optional, default userList
  apiLogin="...",                   // optional — set Corezoid binding later if omitted
  apiSecret="...",
  procId="...",
  companyId="..."
)
→ { actorId: "...", ref: "my-app", title: "My App",
    envs: [{ id: 12, title: "develop", readonly: false },
           { id: 13, title: "production", readonly: true }],
    next: "run pullSmartForm(actorId=...) to download the initial file tree" }

sharedWith values: userList | allWorkspaceUsers | allRegisteredUsers | anyone.

Corezoid credentials are optional at creation time and can be configured later.

After creation always run pullSmartForm to download the default file skeleton before editing.


Working with Releases

Deploy develop → production

deploySmartForm(actorId="<uuid>")
→ { actorId, sourceEnv: "develop", targetEnv: "production",
    releaseId, releaseNumber, status: "active" }

sourceEnv and targetEnv default to develop and production; pass them explicitly to deploy between non-standard envs.

List releases

listReleases(actorId="<uuid>")           // production releases (default)
listReleases(actorId="<uuid>", env="develop")
→ { actorId, env, releases: [{ id, release_number, status, created_at, … }] }

Diff two releases

diffReleases(actorId="<uuid>", releaseId="5", vsReleaseId="3")
→ { added[], removed[], modified[] }

Compared by source_hash — no file bytes transferred. Use before a rollback to preview what will change.

Rollback to a previous release

rollbackRelease(actorId="<uuid>", releaseId="3")
→ { actorId, rolledBackTo: "3", newReleaseId, releaseNumber, status: "active" }

Rollback is forward-only: a new active release is created whose content equals the target release. History is never rewritten.

Retention: 5 releases per env (objects). Older release manifests remain for audit; only objects are GC'd. A release outside the 5-release window cannot be rolled back.


File History

// List versions of a file (fileId from .manifest.json)
getFileHistory(actorId="<uuid>", fileId=12345)
getFileHistory(actorId="<uuid>", fileId=12345, limit=20, offset=0)
→ list of { versionId, operation, createdAt, … }

// Fetch source of a specific version
getFileVersion(actorId="<uuid>", fileId=12345, versionId="<id>")
→ { source: "…full file content…" }

// Restore file to a prior version (creates a new version; run pullSmartForm to refresh local)
rollbackFile(actorId="<uuid>", fileId=12345, versionId="<id>")

// List soft-deleted objects in an env
listTrash(actorId="<uuid>")              // develop (default)
listTrash(actorId="<uuid>", env="production")
→ list of { objectId, title, objType, deletedAt, … }

// Restore a deleted object
restoreFromTrash(actorId="<uuid>", objectId="<id>")

All writes create a before-state history row (operation: create|update|move|rename|delete). Retention: 50 versions per file.


Key Rules for Authoring

  1. Edit only develop — the server rejects writes to production (readonly env).
  2. Run pullSmartForm before editing — establishes .manifest.json needed by pushSmartForm.
  3. Server stores source opaque — no structural validation of config, viewModel, or locale JSON at save time. Validate page JSON against the CDU protocol before pushing.
  4. Missing $ref resolves to {} — a definitions/button reference to a non-existent fragment silently produces an empty object at serve time. Always verify definition names.
  5. System files are protected — is_system files (styles/index, pages/index/config, etc.) cannot be renamed or deleted. Edit their content freely; rename/delete will fail.
  6. CSS is Less — styles/index is compiled with Less at serve time, wrapped in .cdu-page {}. Use Less syntax; @import "pages/<id>/style" imports page-level stylesheets.
  7. Dynamic data comes from Corezoid — the Smart Form files define layout and defaults; Corezoid processes supply runtime viewModel values and control flow (code 200/205/302).
  8. Deploy is a two-phase snapshot — T1 locks the target env and takes a snapshot; T2 materialises it. A failed T2 is compensated automatically.

Typical Session Example

// 1. Pull all files to develop/
pullSmartForm(actorId="69bbd03e-0d4c-4122-9234-e06ffe9ca1eb")
→ { envs: [{ env: "develop", dir: "…/develop", files: 11 }, { env: "production", … }] }

// 2. Edit pages/index/config — add a new label item to the body section

// 3. Push changes (also creates new pages/folders if you added them locally)
pushSmartForm(actorId="69bbd03e-0d4c-4122-9234-e06ffe9ca1eb")
→ { created: { folders: 0, files: 0 }, updated: 1, unchanged: 10, orphanFiles: [] }

// Adding a new page? Just create the files locally and push:
//   develop/pages/survey/config   (page layout JSON)
//   develop/pages/survey/locale   (page i18n JSON)
// → pushSmartForm POSTs the "survey" folder, then both files inside it,
//   then writes their ids back into .manifest.json. No PUT/PATCH needed.

// 4. Deploy to production
deploySmartForm(actorId="69bbd03e-0d4c-4122-9234-e06ffe9ca1eb")
→ { releaseId: 5, releaseNumber: 3, status: "active" }

Embedding a Smart Form into an actor or a reaction

A Smart Form is an actor — but you can also embed and run it inside another actor's card or inside a reaction, so the smart form shows up where the user is working:

  • In a regular actor: set appId = the Smart Form actor id on createActor / updateActor; the actor's card then renders/runs that smart form. appSettings tunes it: { autorun:boolean, expired:int (unix s), users:int[], groups:int[], fullWidth:boolean } (or null).
    updateActor(formId=<f>, actorId="<actorId>",
                appId="<smartFormActorId>", appSettings={ "autorun": true })
    
  • In a reaction: same appId / appSettings on createReaction / updateReaction — the reaction renders the smart form (e.g. drop an interactive form into a discussion thread).
    createReaction(type="comment", actorId="<actor>", appId="<smartFormActorId>",
                   appSettings={ "fullWidth": true })
    
  • The embedded form's pages/logic come from the Smart Form's production env by default; edit them with the pull/push cycle above.

Also see simulator-reactions (the appId + extra.linkedActorId + [application=…] chip) and docs/entities/reactions.md → "Embedding".

Access & Scopes (Public API)

All Smart Form operations use the public API (/papi/1.0/...) with an OAuth2 bearer token.

Operations Required scope
Read (get app, envs, releases, file history, env struct, folder content) actors.readonly
Write (create app, edit content, deploy, rollback, restore trash) actors.management
Page serving (render/submit pages) actors.readonly + sharedWith rules

pullSmartForm and pushSmartForm both require actors.management scope.


Styling (hand-off)

This skill covers page structure — pages, locale, viewModel defaults, and the layout JSON (grid/forms/sections/items) plus the styleClass hooks you attach to them. It does not author the CSS/Less itself. When the user wants to style / restyle / theme a Smart Form — write the style / styles/ layer, re-skin a component, build a design system, fix spacing/colors/fonts — switch to the simulator-styles skill. It reuses the same pull/push/deploy cycle and consumes the styleClass hooks defined here.

Adding Backend Logic

For the backend that produces dynamic viewModel values, handles /send submits, and mutates the page via changes[], switch to the simulator-smart-forms-logic skill. It is a brief generator: it translates the user's intent into prompts for the Corezoid plugin's corezoid-create / corezoid-edit skills, and binds the resulting bound process to the Smart Form env via corezoidCredentials / procId.


Reference Documents

Path When to read
$CLAUDE_PLUGIN_ROOT/docs/user-flows/smart-forms.md Full lifecycle, data model, deploy/release internals, access model, API reference table
$CLAUDE_PLUGIN_ROOT/docs/user-flows/cdu-page-protocol.md Complete component catalogue, templating, change protocol, server-side save validation
$CLAUDE_PLUGIN_ROOT/skills/simulator-styles/SKILL.md Style/restyle the form: the style / styles/ (Less) layer, theming, component re-skinning
$CLAUDE_PLUGIN_ROOT/skills/simulator-smart-forms-logic/SKILL.md Author + bind the Corezoid backend processes for this Smart Form
$CLAUDE_PLUGIN_ROOT/skills/simulator-app-generator/SKILL.md Generate a WHOLE app (many pages + middleware) from a set of existing Corezoid processes, instead of authoring pages one at a time