Imperal Docs
Recipes

Form submission — typed input → handler → refresh panel

ui.Form with Pydantic-typed handler, validation feedback, refresh_panels after write

ui.Form collects all child input values and submits them in a single action. Pair it with a Pydantic params model on the handler (V17 module-scope invariant) to get automatic type coercion and structured validation feedback from the LLM before the function fires. After a successful write, return refresh_panels to update the sidebar without a full page reload. This pattern is the basis of sql-db/panels_editor_row_form.py and web-tools/panels_setup.py.


panels_form.py — complete minimal example
from __future__ import annotations

from pydantic import BaseModel, Field

from imperal_sdk import Extension, ActionResult, ui

ext = Extension(
    "my-ext",
    display_name="My Extension",
    description="An extension demonstrating typed form submission with panel refresh.",
    actions_explicit=True,
)

# ── Stub store — replace with ctx.store or real DB writes ─────────────────
_RECORDS: dict[str, dict] = {}


# ── Pydantic params model — must be at module scope (federal invariant) ───
#
# The SDK auto-derives the JSON schema from this model for the LLM.
# PydanticValidationError triggers a bounded retry loop (max 2) with
# structured prose feedback so the LLM can self-correct bad args.
# Production ref: sql-db/panels_editor_row_form.py

class CreateRecordParams(BaseModel):
    name: str = Field(
        description="Display name for the record. Must be non-empty.",
    )
    category: str = Field(
        default="general",
        description="Record category: 'general' | 'priority' | 'archived'.",
    )
    active: bool = Field(
        default=True,
        description="Whether the record is active on creation.",
    )


# ── chat.function — write handler ─────────────────────────────────────────

@ext.chat.function(  # type: ignore[attr-defined]
    "create_record",
    description="Create a new record with the given name, category, and active flag.",
    action_type="write",
    chain_callable=True,
    effects=["record.create"],
)
async def create_record(ctx: object, params: CreateRecordParams) -> ActionResult:
    if not params.name.strip():
        return ActionResult(status="error", summary="Name must not be empty.")

    record_id = f"rec-{len(_RECORDS) + 1}"
    _RECORDS[record_id] = {
        "id":       record_id,
        "name":     params.name.strip(),
        "category": params.category,
        "active":   params.active,
    }

    # Returning refresh_panels=["sidebar"] re-fetches the left panel after
    # the write commits (Path A: targeted; Path B: fires refreshAll).
    return ActionResult(
        status="ok",
        summary=f"Record '{params.name}' created.",
        refresh_panels=["sidebar"],
    )


# ── Left sidebar — list of records ────────────────────────────────────────

@ext.panel(
    "sidebar",
    slot="left",
    title="Records",
    icon="📜",
    default_width=280,
    min_width=200,
    max_width=480,
)
async def sidebar(ctx: object, **kwargs: object) -> object:
    records = list(_RECORDS.values())
    return ui.Stack(
        gap=3,
        children=[
            ui.Button(
                "New record",
                variant="primary",
                icon="➕",
                full_width=True,
                on_click=ui.Call("__panel__new_record"),
            ),
            ui.List(
                items=[
                    ui.ListItem(
                        id=r["id"],
                        title=r["name"],
                        subtitle=r["category"],
                        badge=ui.Badge("active", color="green") if r["active"]
                              else ui.Badge("inactive", color="gray"),
                        on_click=ui.Call("__panel__new_record", record_id=r["id"]),
                    )
                    for r in records
                ]
                if records
                else [ui.ListItem(id="_empty", title="No records yet")],
            ),
        ],
    )


# ── Center panel — the form ───────────────────────────────────────────────

@ext.panel(
    "new_record",
    slot="center",
    center_overlay=True,
    title="New record",
    icon="📋",
)
async def new_record(ctx: object, **kwargs: object) -> object:
    # ui.Form collects all child input values and submits them as one action.
    # `action` is the @chat.function name — the form build the params dict from
    # child `param_name` values and calls the function directly without LLM routing.
    # `defaults` pre-fills matching param_name keys.
    return ui.Stack(
        gap=4,
        children=[
            ui.Header("Create record", level=2),
            ui.Form(
                action="create_record",
                submit_label="Create",
                defaults={"category": "general", "active": True},
                children=[
                    ui.Input(
                        placeholder="Record name",
                        param_name="name",
                    ),
                    ui.Select(
                        options=[
                            {"value": "general",  "label": "General"},
                            {"value": "priority", "label": "Priority"},
                            {"value": "archived", "label": "Archived"},
                        ],
                        placeholder="Category",
                        param_name="category",
                    ),
                    ui.Toggle(
                        label="Active on creation",
                        value=True,
                        param_name="active",
                    ),
                ],
            ),
        ],
    )

Walk-through

ui.Form(action=..., children=[...]). action is the bare @chat.function name — not a ui.Call(...). On submit, the panel host collects each child's current value (keyed by param_name) into a flat dict and calls the function directly. No LLM routing occurs; the call goes straight to the handler.

defaults pre-fills form fields. defaults is a dict keyed by param_name. The panel host seeds the input state from defaults before first render. Use it to match the Pydantic model's default values so the user sees the right initial state.

Why param_name on each field. Each input primitive has a param_name prop (default varies: "value" for Input, "value" for Select, "enabled" for Toggle). Override with an explicit param_name that matches the Pydantic model field name — otherwise the form submits with the wrong key and Pydantic raises ValidationError.

Pydantic params model at module scope (V17). The SDK auto-detects a Pydantic BaseModel param via inspect.signature. It stores the model on the FunctionDef and uses it to derive the JSON schema the LLM sees. The model MUST be defined at module scope — func.__globals__ is inspected at decoration time. A function-local model causes a ValueError at import.

Bounded Pydantic retry loop (v4.1.0). If the LLM supplies args that fail Pydantic validation, the SDK retries up to 2 times (I-PYDANTIC-RETRY-BUDGET) with structured prose feedback telling the LLM which fields failed and why. On third failure the error propagates. Design field descriptions in the model to be self-correcting: tell the LLM the allowed enum values, the expected format, and the source to obtain the value from.

action_type="write" and refresh_panels. Setting action_type="write" enables the confirmation gate. The write does not fire until the user confirms. After commit, refresh_panels=["sidebar"] (bare panel_id, no __panel__ prefix) re-fetches the sidebar on Path A (direct panel call) or triggers refreshAll() on Path B (chat-initiated SSE). See refresh after write for the full two-path contract.


On this page