Imperal Docs
SDK Reference

ui actions reference

ui.Call / ui.Navigate / ui.Send / ui.Open + auto_action prop format

UI actions are produced by imperal_sdk.ui helper functions and serialized through the UIAction dataclass (imperal_sdk.ui.base.UIAction). They are the four ways a rendered panel dispatches behavior back to the web-kernel or the host application. Actions appear as prop values on interactive UINode components — typically on_click, on_submit, or on_change — and also as the value of the special auto_action prop on a left-panel root node.

All four helpers live at imperal_sdk.ui.actions and are re-exported from imperal_sdk.ui:

from imperal_sdk import ui

action = ui.Call("__panel__editor", note_id="abc")

ui.Call

The most-used action. Triggers a @chat.function or panel handler directly, bypassing the chat pipeline entirely. The web-kernel routes the call based on the function-name prefix.

Signature

Anchored to :

from imperal_sdk import ui
from imperal_sdk.ui.base import UIAction


def Call(function: str, **params) -> UIAction:
    return UIAction(action="call", params={"function": function, "params": params})

ui.Call returns a UIAction with action="call". On the wire the dict is:

# ui.Call("__panel__editor", note_id="abc").to_dict() ==
{"action": "call", "function": "__panel__editor", "params": {"note_id": "abc"}}

Function-name conventions

PrefixDispatch pathTypical use
__panel__{panel_id}System-task path — params spread directly to the handler via **kwargsOpen / refresh a panel
__widget__{widget_id}System-task pathRefresh a widget
__webhook__{name}System-task pathInternal webhook bridge
Plain name (no __ prefix)Chat-function pipeline (the typed-args dispatcher)Call a @chat.function directly from a button

Dispatch matrix

Myth busted: There is no declared-vs-undeclared kwarg split in the web-kernel dispatch path. the web-kernel direct-call dispatch has one routing branch based on is_system_func (whether the function name starts with __panel__, __widget__, or __webhook__). Both kwargs below go through exactly the same web-kernel code path.

If you observed differential behavior, it was a Q2-batch-discovery cache symptom: at discovery time params: {} is sent; at action time the accumulated the accumulated panel params context is merged in. The web-kernel does not inspect param names against the handler signature before dispatching. See Troubleshooting case 4.

from imperal_sdk import ui

# Both kwargs are dispatched identically by web-kernel and frontend.
# "undeclared" in the handler signature — same path as declared.
on_click_a = ui.Call("__panel__editor", note_id="abc")

# "declared" in the handler's **params — same path.
on_click_b = ui.Call("__panel__editor", active_view="plan")

The only place where declared vs undeclared matters is inside your handler: if you annotate a @chat.function with a strict Pydantic model, unknown keys raise a ValidationError. That is extension-authored policy, not a host contract.

Examples

Panel open from a list item:

from imperal_sdk import Extension, ui

ext = Extension(app_id="my-ext")


@ext.panel("sidebar", slot="left", title="Items")
async def sidebar(ctx, **params):  # type: ignore[attr-defined]
    items = [{"id": "1", "title": "First item"}, {"id": "2", "title": "Second item"}]
    return ui.List(
        items=[
            ui.ListItem(
                id=item["id"],
                title=item["title"],
                on_click=ui.Call("__panel__detail", item_id=item["id"]),
            )
            for item in items
        ]
    )

Calling a @chat.function from a button:

from imperal_sdk import Extension, ui, ActionResult

ext = Extension(app_id="my-ext")


@ext.panel("editor", slot="center", title="Editor", center_overlay=True)
async def editor(ctx, **params):  # type: ignore[attr-defined]
    item_id: str = params.get("item_id", "")
    return ui.Stack(
        direction="v",
        children=[
            ui.Text(f"Editing: {item_id}"),
            ui.Button(
                label="Archive",
                on_click=ui.Call("archive_item", item_id=item_id),
            ),
        ],
    )

ui.Navigate

Client-side navigation. When the host receives a Navigate action it pushes path onto its router — equivalent to the user clicking a link.

Signature

from imperal_sdk import ui
from imperal_sdk.ui.base import UIAction


def Navigate(path: str) -> UIAction:
    return UIAction(action="navigate", params={"path": path})

Wire shape: {"action": "navigate", "path": "/settings"}.

When to use

Use for "after save, jump to a different page" or "take the user to another extension's URL". Navigate does not open a panel — it replaces the host's current route.

Example

from imperal_sdk import Extension, ui, ActionResult

ext = Extension(app_id="my-ext")


@ext.panel("onboarding", slot="center", title="Welcome", center_overlay=True)
async def onboarding(ctx, **params):  # type: ignore[attr-defined]
    return ui.Stack(
        direction="v",
        children=[
            ui.Header("Get started"),
            ui.Button(
                label="Go to settings",
                on_click=ui.Navigate("/settings"),
            ),
        ],
    )

ui.Send

Sends a pre-formed message into chat as if the user typed it. Useful for quick-action buttons that trigger a chat interaction without leaving the panel.

Signature

from imperal_sdk import ui
from imperal_sdk.ui.base import UIAction


def Send(message: str) -> UIAction:
    return UIAction(action="send", params={"message": message})

Wire shape: {"action": "send", "message": "Summarise this folder"}.

When to use

Use when a panel button should kick off a chat task — for example, a "Summarise" button on a folder view that sends a ready-made query. The message is injected into the chat input and submitted exactly as if the user had typed it.

Example

from imperal_sdk import Extension, ui

ext = Extension(app_id="my-ext")


@ext.panel("folder", slot="left", title="Folder")
async def folder(ctx, **params):  # type: ignore[attr-defined]
    folder_id: str = params.get("folder_id", "")
    return ui.Stack(
        direction="v",
        children=[
            ui.Header("Folder actions"),
            ui.Button(
                label="Summarise contents",
                on_click=ui.Send(f"Summarise folder {folder_id}"),
            ),
        ],
    )

ui.Open

Opens a URL in a new browser tab. Use sparingly — it takes the user away from the panel UI to an external page.

Signature

from imperal_sdk import ui
from imperal_sdk.ui.base import UIAction


def Open(url: str) -> UIAction:
    return UIAction(action="open", params={"url": url})

Wire shape: {"action": "open", "url": "https://example.com"}.

When to use

Use for "view source", "open in external app", or "download link" patterns where the content lives outside the Imperal Panel. Prefer ui.Navigate for in-app routes.

Example

from imperal_sdk import Extension, ui

ext = Extension(app_id="my-ext")


@ext.panel("detail", slot="right", title="Detail")
async def detail(ctx, **params):  # type: ignore[attr-defined]
    doc_url: str = params.get("doc_url", "")
    return ui.Stack(
        direction="v",
        children=[
            ui.Text("View the original document:"),
            ui.Button(
                label="Open in browser",
                on_click=ui.Open(doc_url),
            ),
        ],
    )

auto_action prop format

Constraint: auto_action is read only from leftPanel.props.auto_action — not from the right panel, center overlay, or any nested UINode child. The routing of the triggered call (whether it opens a center overlay or refreshes a slot panel) is decided by a hardcoded the center-overlay routing rule function in the frontend, not by the slot= metadata in your extension manifest. Adding a new extension with a center-overlay auto_action currently requires a frontend code change. See auto_action does not re-fire on revisit and Recipe: panel-auto-action-on-load.

auto_action is a prop attached to the root UINode of the left panel only. After batch discovery completes (when leftPanel becomes non-null), the host's useEffect reads leftPanel.props.auto_action and dispatches it once per extension page session, guarded by the discovery-once guard. Navigating away and back to the same extension page resets the guard — auto_action will fire again on re-discovery.

The format is a UIAction produced by any of the four helpers. In production it is always ui.Call.

Setup pattern

Production reference: notes/panels.py.

from imperal_sdk import Extension, ui

ext = Extension(app_id="my-ext")


@ext.panel("sidebar", slot="left", title="Notes")
async def sidebar(ctx, **params):  # type: ignore[attr-defined]
    notes = [{"id": "n1", "title": "First note"}, {"id": "n2", "title": "Second note"}]
    root = ui.List(
        items=[
            ui.ListItem(
                id=n["id"],
                title=n["title"],
                on_click=ui.Call("__panel__editor", note_id=n["id"]),
            )
            for n in notes
        ]
    )
    if notes:
        # Conditional: only set when there is content to open.
        root.props["auto_action"] = ui.Call(
            "__panel__editor", note_id=notes[0]["id"]
        )
    return root

Fire-once semantics

the discovery-once guard is set to true the first time the effect fires. Subsequent leftPanel updates (e.g. from a panel refresh after a write action) re-evaluate the effect but the guard blocks re-dispatch. This means:

  • auto_action fires once per session (once per navigation to the extension page).
  • It does not re-fire when the left panel refreshes after user interaction.
  • It does re-fire if the user navigates away and returns (discovery resets the discovery-once guard).

Common mistakes

Root-only. auto_action must be set on the UINode that is the direct return value of your handler (the root), not on a nested child. Setting auto_action on a ui.ListItem, a nested ui.Stack, or any non-root node is a silent no-op — the frontend only reads leftPanel.props.auto_action at the top level.

Right panel is ignored. If an extension sets auto_action on the root of a right-panel handler, it is silently ignored. There is no mechanism for right-panel or center-overlay auto_action.

return None blocks auto_action. If your left-panel handler returns None, the slot setter is never called, leftPanel stays null, and the auto_action useEffect guard (!leftPanel) blocks. Use ui.Empty(message="...") when there is nothing to show.


Where next

On this page