Codelab
Registry

Props

Prop slots on an element — set/unset commands, literals vs {{…}} expressions, FunctionModel event bindings, and why most HTML/React props are not settable.

A prop slot is one named key on an element's props record — children, variant, open, onClick. You fill a slot with a literal, a {{…}} expression, or (on a Function slot) a FunctionModel. You never send the whole props object.

packages/modules/props/kernel/src/props.record.ts

Which keys exist is not "whatever React or HTML accepts." A slot is settable only if that kind declares it. Primitive declarations live in *.metadata.ts. User-component declarations are that component's fields. The write path validates against a strict schema built from those fields; an extra key is Unrecognized key.

packages/modules/props/core/src/domain/props.validation.ts packages/modules/element/server/src/services/update-element.service.ts

See Registry for kinds. This page is how you set values.

What is on a slot

Every kind also accepts global fields, prepended at registry construction (packages/modules/schema/kernel/src/schema.globals.ts): id, aria-label, className, style, role, key, and the aria-* state attributes (aria-hidden, aria-expanded, aria-pressed, aria-current, aria-controls, aria-labelledby, aria-describedby, aria-live, aria-invalid, aria-valuenow / min / max).

data-* keys (data-state, data-favorite, …) are allowed by shape on every kind — they are not listed per metadata file (packages/modules/props/core/src/domain/props.validation.ts).

class is blocked. Use className. style is a declared global (opaque CSS object, expression-capable); day-to-day styling is still the style_* tools.

packages/modules/props/core/src/domain/props.schema.ts

A slot's type (text, boolean, function, …) is a FieldType. The validator checks the stored value (literal or expression string). The renderer evaluates a whole {{…}} to its native type at render.

Set/unset, never replace

update_props takes a command list. Last write wins for duplicate keys in one call.

{
  "elementId": "<id>",
  "commands": [
    { "op": "set", "key": "variant", "value": "outline" },
    { "op": "unset", "key": "loading" }
  ]
}

packages/modules/props/adapter/src/ai/update-props.schema.ts packages/modules/mcp/server/src/element.mcp.ts

The handler merges into the existing record, then persists. Do not send every current prop. A full-object replace would force the model to echo every value on every call — token cost plus LLM fidelity drift (keys dropped, strings rewritten). Send only the slots you are changing.

unset removes the key so the vendor default / omitted prop applies.

update_element uses the same command shape for node fields (kind, repeatable, repeatSource, renderIf) — not for props. children, href, src, placeholder, instance fields, expressions, and FunctionModel bindings are update_props.

packages/modules/element/adapter/src/ai/update-element.tool.ts

Create vs update

add_element.props is an array of { key, value } where value is only string, number, or boolean.

packages/modules/props/core/src/domain/props.schema.ts packages/modules/element/adapter/src/ai/add-element.schema.ts

{
  "props": [
    { "key": "children", "value": "Save" },
    { "key": "variant", "value": "outline" },
    { "key": "className", "value": "w-full" }
  ]
}

A whole {{…}} binding is a string, so it can ride a text/boolean slot on create. Objects cannot — FunctionModel event bindings, nested records, arrays. Set those with update_props after the element exists. add_elements is the same primitive-value rule; Component kinds may also pass instanceProps as { key, value } entries, folded into the same record (packages/modules/element/adapter/src/ai/add-elements.schema.ts).

Read current values with inspect_element. It returns stored props (nulls stripped), not the kind's schema.

Expressions in string slots

Anywhere a slot stores a string (or a boolean/number that also accepts a string), a whole {{…}} is an expression evaluated at render.

{ "op": "set", "key": "value", "value": "{{hooks.rsvpForm.values.email}}" }
{ "op": "set", "key": "open", "value": "{{stores.rsvp.modalOpen}}" }

A whole binding ("{{stores.rsvp.modalOpen}}") canonicalizes to { "_tag": "Expression", "source": "{{stores.rsvp.modalOpen}}" } at the update chokepoint (packages/modules/expression/kernel/src/expression.write.schema.ts). Mixed text ("Hello {{stores.user.name}}") stays a string and interpolates — it is not upgraded to a tagged expression.

Common namespaces (packages/modules/expression/core/src/domain/expression.namespaces.ts):

ExpressionMeaning
{{stores.<name>.<field>}}Live store state.
{{hooks.<alias>.<key>}}Hook output (values, errors, data, …).
{{props.<key>}}Enclosing component instance.
{{event}}First argument to the handler. On onValueChange / onOpenChange / onCheckedChange, that argument is the new value.
{{event.target.value}}Native onChange on an input.
{{repeat.item}} / {{repeat.index}}Inside a repeated subtree.
{{session.*}} / {{page.*}}Ambient viewer / route.

Type.Function, Type.Component, and Type.Element slots do not accept a bare {{…}} — they store a binding record. Boolean/text/object/array slots do.

Declare every store field you bind, with a defaultValue. An undeclared field is undefined on first paint and flips controlled inputs.

Action bindings on event props

An event slot (onClick, onChange, onValueChange, onOpenChange, onSubmit, …) is Type.Function. The stored value is a FunctionModel:

{
  "functionId": "set-property",
  "instanceParams": {
    "store": "rsvp",
    "property": "modalOpen",
    "value": true
  }
}

packages/modules/action/kernel/src/function.model.ts

functionId is a primitive slug (list_primitive_functions), a store action id, or "<hookId>.<method>" from list_hooks. Full catalog: Action. One FunctionModel per element slot — sequences live on a store action, pointed at from N slots.

This is an object, so it goes through update_props, not add_element.props:

{
  "elementId": "<buttonId>",
  "commands": [
    {
      "op": "set",
      "key": "onClick",
      "value": {
        "functionId": "set-property",
        "instanceParams": {
          "store": "rsvp",
          "property": "modalOpen",
          "value": true
        }
      }
    }
  ]
}

instanceParams values may themselves be {{…}}. For Dialog / Switch / Select, round-trip with "{{event}}":

{
  "op": "set",
  "key": "onOpenChange",
  "value": {
    "functionId": "set-property",
    "instanceParams": {
      "store": "rsvp",
      "property": "modalOpen",
      "value": "{{event}}"
    }
  }
}

Pointer events (onClick, onMouseEnter, onMouseLeave) are not global. They are spread only onto kinds whose wrapper forwards rest props to the node the user actually clicks (packages/modules/registry/core/src/domain/event-fields.ts). A handler on a wrapper that never fires is worse than missing.

Why most HTML / React props are not settable

The vendor component still accepts native attributes. The builder does not persist them unless they are fields.

  1. Strict schema. z.strictObject from declared fields. Extra keys fail. MCP, the prop editor, and insert all go through that schema.
  2. Opt-in DOM surface. Coss wrappers inherit hundreds of HTML attributes. Declaring all of them on every kind would drown the props form. Bundles (TEXT_INPUT_FIELDS, INTERACTION_EVENT_FIELDS) exist so input-ish kinds stay in step — they are still an explicit spread, not a global (packages/modules/registry/core/src/domain/input-fields.ts).
  3. Names must be real vendor props — the reverse is false. PrimitiveMetadataFor<TProps> is a compile-time check that a metadata name exists on the component. It does not require every key of TProps to be declared (packages/modules/registry/core/src/domain/registry.model.types.ts).
  4. Some vendor props are deferred. Out-refs such as actionsRef and inputRef have no authorable value yet (packages/modules/registry/core/src/coss/coss.schema-parity.ts).
  5. HTML div is not a grab-bag. packages/modules/registry/core/src/html/html.registry.metadata.ts gives a div children plus globals. onClick, tabIndex, title, hidden are not slots. Put a click on a Button (or an a), not a div.
  6. Native form attributes that fight the hook flow are omitted. coss Form declares errors, onSubmit, validationMode — not HTML action / method (packages/modules/registry/core/src/coss/metadata/form.metadata.ts). Those would trigger a full-page POST.

If update_props rejects a key, that prop is not declared for the kind. Do not try to smuggle it through className or a data-* except where a data attribute is actually what you mean.

Examples

Button

Fields: packages/modules/registry/core/src/coss/metadata/button.metadata.ts.

Create with literals; bind the click after:

{
  "kind": { "_tag": "Primitive", "value": "Button" },
  "props": [
    { "key": "children", "value": "Open RSVP" },
    { "key": "variant", "value": "default" },
    { "key": "type", "value": "button" }
  ]
}
{
  "elementId": "<buttonId>",
  "commands": [
    {
      "op": "set",
      "key": "onClick",
      "value": {
        "functionId": "set-property",
        "instanceParams": {
          "store": "rsvp",
          "property": "modalOpen",
          "value": true
        }
      }
    }
  ]
}

loading and disabled are booleans and also accept "{{stores.ui.pending}}".

Inside a form that submits via a hook method, set type to "button" so the native submit does not fire beside handleSubmit.

Input (controlled)

Fields: packages/modules/registry/core/src/coss/metadata/input.metadata.ts.

{
  "elementId": "<inputId>",
  "commands": [
    { "op": "set", "key": "name", "value": "email" },
    { "op": "set", "key": "type", "value": "email" },
    { "op": "set", "key": "value", "value": "{{hooks.rsvpForm.values.email}}" },
    {
      "op": "set",
      "key": "onChange",
      "value": {
        "functionId": "<rsvpForm-hookId>.setFieldValue",
        "instanceParams": {
          "name": "email",
          "value": "{{event.target.value}}"
        }
      }
    }
  ]
}

functionId uses the hook id from list_hooks, not hooks.rsvpForm. The expression still uses the alias.

Dialog (controlled overlay)

Inspect first: inspect_element_type({ "kind": "Dialog" }). Fields on the root: packages/modules/registry/core/src/coss/metadata/dialog.metadata.tsopen, onOpenChange, modal, defaultOpen, handle, …

{
  "elementId": "<dialogId>",
  "commands": [
    { "op": "set", "key": "open", "value": "{{stores.rsvp.modalOpen}}" },
    {
      "op": "set",
      "key": "onOpenChange",
      "value": {
        "functionId": "set-property",
        "instanceParams": {
          "store": "rsvp",
          "property": "modalOpen",
          "value": "{{event}}"
        }
      }
    }
  ]
}

Outside-click and Escape call onOpenChange(false) → the store flips → open re-resolves. The store field must exist (defaultValue: false) or open starts undefined.

showCloseButton / bottomStickOnMobile live on DialogPopup, not on Dialog. children on DialogTitle is the title string.

Component instances

On a kind: { _tag: "Component", value: "<id>" } node, slots are that component's fields plus globals. list_components then load_component_outline for the schema. Set instance values with update_props (or instanceProps on add_elements).

Inside the definition, bind_prop writes {{props.<key>}} onto an inner element's slot:

{ "elementId": "<headingId>", "propKey": "title", "targetProp": "children" }

packages/modules/props/adapter/src/ai/bind-prop.tool.ts

You can set the same expression yourself with update_props. bind_prop is the dedicated form. set_props_schema replaces the whole fields array — not incremental.

MCP tools

ToolRole
list_element_types / inspect_element_typeKnow the kind before you set slots.
inspect_elementCurrent stored props.
add_element / add_elementsLiterals only (string | number | boolean).
update_propsSet/unset slots, expressions, FunctionModel.
list_primitive_functionsPrimitive functionId + param names.
list_stores / list_hooksNames and ids expressions and bindings point at.
list_components / load_component_outlineInstance field schema.
bind_prop / set_props_schemaComponent definition wiring.

Gotchas

  • Commands, not a blob. Merge is server-side. Re-sending the whole props object is how keys vanish. (packages/modules/mcp/server/src/element.mcp.ts)
  • Unrecognized key. The kind does not declare it. Check Registry metadata, not the React types.
  • onClick is not universal. Button yes; Input no; div no.
  • functionId, not a JS body. The element stores a pointer. Bodies live on store code-actions. (packages/modules/action/kernel/src/function.model.ts)
  • {{event}} vs {{event.target.value}}. Base UI change/open/checked handlers pass the value as the first argument. Native onChange still uses the DOM event.
  • class is invalid. className only. (packages/modules/props/adapter/src/ai/command-resolver.ts)
  • aria-label is a DOM name, not an editor tag. Set it when the node has no accessible name of its own (icon-only button). Visible text already names the control — a second label overrides it in the accessibility tree. (packages/modules/element/adapter/src/ai/add-element.tool.ts)

On this page