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
| Prefix | Dispatch path | Typical use |
|---|---|---|
__panel__{panel_id} | System-task path — params spread directly to the handler via **kwargs | Open / refresh a panel |
__widget__{widget_id} | System-task path | Refresh a widget |
__webhook__{name} | System-task path | Internal 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 rootFire-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_actionfires 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
Panels concept
Slot ownership, lifecycle, batch discovery, and the dispatch model.
Decorators reference
@ext.panel signature, slot allowlist, refresh modes, and kwargs.
Recipe: auto-action on load
End-to-end walkthrough of the auto_action pattern.
Panel troubleshooting
Cases 3 (auto_action not re-firing) and 4 (dispatch-split myth).