Imperal Docs
Guides

Troubleshoot a panel

Symptom → cause → fix for the ten most common panel bugs

If a panel is not rendering as expected, locate the symptom below and follow the diagnostic. Each case maps to a verified contract finding from the Phase 0 audit (2026-05-08-panel-contract-audit-findings.md). Read through the Cause section before reaching for a fix — the root cause is often one layer above where you are looking.

Ten cases are covered:

#SymptomAudit anchor
1Center overlay does not open even though ui.Call("__panel__editor", note_id=...) firesQ1, Q2, Q3, Q4
2Panel refreshes instead of replacingQ1
3auto_action does not fire after the user navigates away and backQ4
4Declared kwarg appears to dispatch differently than undeclaredQ5
5Batch discovery consumes the slot you wanted auto_action to claimQ2, Q3
6refresh_panels does not pick up fresh data — or refreshes more panels than expectedQ6, Q13
7Multi-tab right panel: switching between tabs does nothingQ9
8slot="overlay" renders nothingQ10, Q4
9@panels.sidebar / @panels.editor decorators not found§1.1
10refresh_seconds=N decorator kwarg silently ignoredQ11, Q12

1. Center overlay does not open

Symptom. A click in the left sidebar fires a ui.Call("__panel__editor", note_id=...) action. The web-kernel routes the call successfully (you can confirm it in audit logs). The center area stays mounted with whatever was visible at session start — the editor your handler returns never appears.

Cause. Your @ext.panel("editor", slot="center", center_overlay=True, ...) handler returned a UINode during batch discovery — the host eagerly calls every configured __panel__* at session start with params: {} to populate its slots. The center slot is now claimed with that initial content. A subsequent ui.Call for the same panel_id just refreshes the already-mounted panel in place and auto_action does not fire again because the discovery-once guard is already true.

The secondary cause is often that the handler did not guard on the identifying kwarg:

before — handler always returns content
from imperal_sdk import Extension, ui

ext = Extension("my-ext", display_name="My Extension", description="...")

@ext.panel("editor", slot="center", center_overlay=True, title="Editor")
async def editor(ctx: object, note_id: str = "", **kwargs: object) -> object:
    # BUG: returns content even when note_id is empty
    # → batch discovery claims center at session start
    return ui.Text("Editor placeholder")  # returns unconditionally

Fix. Return ui.Empty(...) when the editor is invoked without an item-identifying kwarg, so the slot is held but visually empty and does not block auto_action. Then set auto_action on the sidebar's root UINode to claim center as soon as the sidebar is populated:

after — editor yields center until sidebar directs it
from imperal_sdk import Extension, ui

ext = Extension("my-ext", display_name="My Extension", description="...")

# Inline sample data — replace with your real fetch in production.
_SAMPLE_NOTES = [{"id": "note-1", "title": "First note"}, {"id": "note-2", "title": "Second note"}]


@ext.panel("editor", slot="center", center_overlay=True, title="Editor")
async def editor(ctx: object, note_id: str = "", **kwargs: object) -> object:
    if not note_id:
        # Return an empty placeholder at batch discovery.
        # The slot is held (leftPanel stays non-null) but auto_action can still fire.
        return ui.Empty(message="Select a note to edit", icon="📄")
    note = next((n for n in _SAMPLE_NOTES if n["id"] == note_id), None)
    if note is None:
        return ui.Error(message="Note not found")
    return ui.Stack(children=[ui.Text(note["title"], variant="h2")])


@ext.panel("sidebar", slot="left", title="Notes")
async def sidebar(ctx: object, **kwargs: object) -> object:
    notes = _SAMPLE_NOTES  # replace with your real data fetch
    root = ui.Stack(children=[
        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:
        # auto_action fires once after discovery when centerOverlay is null.
        root.props["auto_action"] = ui.Call(
            "__panel__editor", note_id=notes[0]["id"]
        )
    return root

Why. Slot ownership is replace-only: when the panel call runs, it unconditionally calls the left-slot mount / the right-slot mount with the new UINode. There is no stack. If your handler returned content at discovery, the center is claimed from that moment. auto_action is guarded by the discovery-once guard — it fires exactly once per discovery cycle, only when leftPanel is non-null, centerOverlay is null, and the ref has not been set. The empty-placeholder pattern keeps leftPanel non-null (enabling auto_action) while leaving center available. See slot ownership in the concept page and lifecycle for the discovery sequence.


2. Panel refreshes instead of replacing

Symptom. You call ui.Call("__panel__X", ...) expecting to swap a completely different panel into the slot, but the slot re-renders with the same panel (or appears not to change at all).

Cause. Replace IS the only mode. There is no stacking. When the panel call is called, it merges the new params into the accumulated panel params[panelId] and calls the left-slot mount(d.ui) / the right-slot mount(d.ui) with whatever the handler returned for those merged params. If the slot appears unchanged, one of three things is happening:

  1. Your handler branches on params but the relevant kwarg was not included in the ui.Call, so the handler returned the same content as before.
  2. The new ui.Call arrived while a previous in-flight the panel call was still resolving — both calls ran concurrently and the old response resolved last, overwriting the new one (no cancellation mechanism exists).
  3. The handler returned None or a falsy d.ui — in that case the left-slot mount / the right-slot mount is not called and the slot retains its previous content.

Fix. Confirm your handler actually branches on the identifying kwarg:

handler that branches correctly on kwarg
from imperal_sdk import Extension, ui

ext = Extension("my-ext", display_name="My Extension", description="...")

_ITEMS = [{"id": "item-1", "title": "Item 1"}, {"id": "item-2", "title": "Item 2"}]


@ext.panel("editor", slot="center", center_overlay=True, title="Editor")
async def editor(
    ctx: object,
    item_id: str = "",
    mode: str = "view",
    **kwargs: object,
) -> object:
    if not item_id:
        return ui.Empty(message="Select an item", icon="🖱️")
    item = next((i for i in _ITEMS if i["id"] == item_id), None)
    if item is None:
        return ui.Error(message="Item not found")
    if mode == "edit":
        return ui.Stack(children=[ui.Text("Edit form for: " + item["title"])])
    return ui.Stack(children=[ui.Text(item["title"], variant="h2")])

Then verify the calling ui.Call passes the kwarg you are branching on:

sidebar on_click must pass item_id
from imperal_sdk import ui

_ITEMS_CALL = [{"id": "item-1", "title": "Item 1"}]
on_click = ui.Call("__panel__editor", item_id=_ITEMS_CALL[0]["id"], mode="view")

Why.: the left-slot mount(append ? ... : d.ui) — last write wins, no stack. The params cache the accumulated panel params accumulates kwargs across calls; a call with {} merges with previously accumulated context. Your handler must inspect the merged params to return differentiated content. See slot ownership.


3. auto_action does not fire after navigating away and back

Symptom. auto_action works on first page load. The user navigates to a different extension (or a different page in the app) and then returns. auto_action does not fire a second time — the center overlay stays empty.

Cause. auto_action fires once per discovery cycle. It is guarded by the discovery-once guard, which is set to true after the first fire. The ref is reset to false only at the start of a new discovery cycle — when extId, leftId, or rightId changes (the the discovery key guard key changes). If the user navigates away and back to the same extension page with the same panel IDs, the discovery useEffect sees the discovery key guard.current === key and returns early — no re-discovery, no ref reset, no auto_action.

The trigger conditions for auto_action to re-fire (all must be true):

  1. discovering === false
  2. the discovery-once guard === false (only true at fresh discovery start)
  3. centerOverlay === null
  4. leftPanel is non-null
  5. leftPanel.props.auto_action has action === 'call' and a function field

Fix. If your extension needs to re-open the center on every visit, trigger the center panel explicitly in your sidebar handler by embedding an auto_action conditional on session state that the sidebar itself re-evaluates. Since auto_action only fires once, consider having the sidebar's content include a prompt card or a sticky "Open last item" affordance:

sidebar with manual re-open affordance
from imperal_sdk import Extension, ui

ext = Extension("my-ext", display_name="My Extension", description="...")

_NOTES = [{"id": "note-1", "title": "Note 1"}, {"id": "note-2", "title": "Note 2"}]


@ext.panel("sidebar", slot="left", title="Notes")
async def sidebar(ctx: object, last_note_id: str = "", **kwargs: object) -> object:
    notes = _NOTES  # replace with your real data fetch
    items = [
        ui.ListItem(
            id=n["id"],
            title=n["title"],
            on_click=ui.Call("__panel__editor", note_id=n["id"]),
        )
        for n in notes
    ]
    # Set auto_action for fresh discovery. On revisit, auto_action
    # won't re-fire (the discovery-once guard), but the list is still
    # interactive — user clicks to open.
    root = ui.Stack(children=[ui.List(items=items)])
    if notes and not last_note_id:
        root.props["auto_action"] = ui.Call(
            "__panel__editor", note_id=notes[0]["id"]
        )
    return root

If you need mandatory re-open on every visit, this requires a frontend change to reset the discovery-once guard on navigation — file a web-kernel/platform ticket rather than working around it in extension code.

Why. the discovery-once guard is intentional: without it, a second leftPanel state update (e.g., from an SSE-triggered panel refresh) would re-fire auto_action on every refresh. The guard trades "always fires" for "fires once per session." The reset hook at only runs when the discovery key changes. See lifecycle and for the full auto_action contract.


4. Declared kwarg appears to dispatch differently than undeclared

Symptom. You have two ui.Call invocations for the same panel:

two calls — same panel_id, different kwargs
from imperal_sdk import ui

# Call A — kwarg declared in handler signature
_call_a = ui.Call("__panel__editor", active_view="plan")

# Call B — kwarg not declared in handler signature
_call_b = ui.Call("__panel__editor", note_id="board")

Call B opens a center overlay; Call A seems to refresh the right slot. You conclude that undeclared kwargs take a different path than declared ones.

Cause. This is a Q2-batch-discovery cache symptom, not a web-kernel routing split. declared and undeclared kwargs are dispatched identically by both the web-kernel and the frontend (the web-kernel direct-call dispatch). The web-kernel's __panel__* dispatch path spreads all params as **params to the handler — it never inspects which params the handler signature declares.

The split you observed comes from the center-overlay routing rule in :

# the center-overlay routing rule allowlist — TypeScript logic
# (shown as pseudocode for readability — this is frontend code, not Python)
#
# pid == "editor"       AND note_id is present  → center overlay
# pid == "compose"                               → center overlay
# pid == "email_viewer" AND message_id present   → center overlay
# Everything else                                → the left-slot mount / the right-slot mount

Call B passed note_id=, which matches the the center-overlay routing rule check — so it opened as a center overlay. Call A passed active_view=, which does not appear in the center-overlay routing rule — so it refreshed the right slot. The web-kernel did not make this decision; the frontend did, based on panel_id + kwarg combination, not on declared vs undeclared status.

Fix. If you need your extension's panel to open as a center overlay, use panel_id="editor" and pass note_id=<identifying_value> — or panel_id="compose" / panel_id="email_viewer" with message_id=. These are the only combinations in the current the center-overlay routing rule allowlist. If none of those match your use case, you need a frontend change to extend the allowlist — file a platform ticket.

structure your handler to branch on kwarg presence
from imperal_sdk import Extension, ui

ext = Extension("my-ext", display_name="My Extension", description="...")


@ext.panel("editor", slot="center", center_overlay=True, title="Editor")
async def editor(
    ctx: object,
    note_id: str = "",
    active_view: str = "",
    **kwargs: object,
) -> object:
    # Both note_id and active_view arrive via identical dispatch paths.
    # Branch on presence/value, not on "declared vs undeclared".
    if note_id:
        return ui.Stack(children=[ui.Text("Editing note: " + note_id)])
    if active_view:
        return ui.Stack(children=[ui.Text("View: " + active_view)])
    return ui.Empty(message="Select a note", icon="📄")

Why. is_system_func = function_name.startswith("__panel__") — all panel calls go through the same spread path with no param-name inspection. The the typed-args dispatcher Pydantic-dispatch path applies only to @chat.function calls. See decoration for the full dispatch model.


5. Batch discovery consumes the slot you wanted auto_action to claim

Symptom. At session start, your editor panel appears in the center slot immediately (populated with fallback content). auto_action never fires. The sidebar loads, but the center overlay never activates.

Cause. At extension page mount, the panel discovery hook fires a single batch POST to the platform batch-discovery endpoint with params: {} for each configured panel slot. If your editor handler returns a UINode unconditionally — even a "no item selected" state — the right-slot mount or the center-overlay mount is called with that content. For auto_action to activate, leftPanel must be non-null AND centerOverlay must be null. If discovery populated the center (because d.ui was truthy), centerOverlay is non-null and the auto_action guard blocks.

auto_action reads leftPanel.props.auto_action — not rightPanel.props.auto_action. It also requires centerOverlay === null at the moment it evaluates. If your discovery handler already wrote to the center overlay state, auto_action will not fire.

Fix. Return ui.Empty(...) from the editor handler when called without an identifying kwarg. This keeps leftPanel non-null (the host sets it to the ui.Empty UINode, which is truthy) while leaving centerOverlay null:

editor returns Empty at discovery — allows auto_action to fire
from imperal_sdk import Extension, ui

ext = Extension("my-ext", display_name="My Extension", description="...")

_ITEMS = [{"id": "item-1", "name": "First item"}, {"id": "item-2", "name": "Second item"}]


@ext.panel("editor", slot="center", center_overlay=True, title="Editor")
async def editor(ctx: object, item_id: str = "", **kwargs: object) -> object:
    if not item_id:
        # ui.Empty is non-null — slot is "held" visually empty.
        # centerOverlay stays null → auto_action guard passes.
        return ui.Empty(message="Select an item to open the editor", icon="🖱️")
    found = next((i for i in _ITEMS if i["id"] == item_id), None)
    if found is None:
        return ui.Error(message="Item not found")
    return ui.Stack(children=[ui.Text(found["name"], variant="h2")])


@ext.panel("sidebar", slot="left", title="Items")
async def sidebar(ctx: object, **kwargs: object) -> object:
    items = _ITEMS  # replace with your real data fetch
    root = ui.Stack(children=[
        ui.List(items=[
            ui.ListItem(
                id=item["id"],
                title=item["name"],
                on_click=ui.Call("__panel__editor", item_id=item["id"]),
            )
            for item in items
        ])
    ])
    if items:
        # sidebar sets auto_action → fires after discovery completes
        root.props["auto_action"] = ui.Call(
            "__panel__editor", item_id=items[0]["id"]
        )
    return root

Use ui.Empty(message=..., icon=...) rather than return None. Production reference extensions (notes, tasks, sql-db) never return None from decorated panel handlers — return None leaves the slot null, which also blocks auto_action because the guard requires leftPanel to be non-null. ui.Empty is the canonical empty-state pattern.

Why.: if results[idx]?.ui is truthy, the slot setter fires. : return Noneresult is None → no ui key → the left-slot mount not called → slot stays nullauto_action guard !leftPanel blocks. The ui.Empty path returns a UINode with to_dict()ui key present → the left-slot mount(Empty_UINode)leftPanel is non-null → guard passes. See lifecycle and slot ownership.


6. refresh_panels does not pick up fresh data — or refreshes more panels than expected

Symptom A. Your @chat.function returns ActionResult(refresh_panels=["sidebar"]), but after the action completes the sidebar shows stale data.

Symptom B. You return refresh_panels=["sidebar"] expecting only the sidebar to refresh, but all panels in the extension refresh.

Cause. There are two distinct code paths for refresh_panels with different scoping behavior:

Path A — Panel button initiates the action (user clicks a button rendered inside a panel, not in chat): The frontend reads refresh_panels from the HTTP response body and calls the panel call per listed panel_id — targeted. This path works as documented.

Path B — Chat initiates the action (user asks Webbee in chat; the chat workflow runs the function): The web-kernel publishes a panel_refresh SSE event with data.panels: ["sidebar"]. However, the panel host's SSE consumer (useEvent('*', ...)) ignores data.panels entirely — it calls refreshAll() unconditionally when any matching event arrives. Every discovered panel re-fetches. This is the "too many panels" symptom.

Additionally, Path B only fires if the extension's refresh config includes on_event:<appId>.panel_refresh. If the extension uses the default refresh="manual", the SSE event is silently dropped and no re-fetch occurs at all — the "stale data" symptom.

Fix. For chat-initiated refreshes, design panels to be cheap and idempotent on full refresh — do not depend on per-panel scoping from chat. For panel-button-initiated refreshes, refresh_panels works as expected (Path A is targeted).

action returning refresh_panels — works on Path A; triggers refreshAll on Path B
from imperal_sdk import Extension, ActionResult

ext = Extension("my-ext", display_name="My Extension", description="...")


@ext.chat.function  # type: ignore[attr-defined]
async def create_note(ctx: object, title: str, body: str) -> ActionResult:
    # Replace with your real store call.
    return ActionResult(
        status="ok",
        summary=f"Created note '{title}'.",
        refresh_panels=["sidebar"],  # targeted on Path A; ignored on Path B
    )

To ensure chat-initiated refreshes also work, add on_event: to your panel declaration and emit the matching SSE event from your write functions. Or accept that chat-path always refreshes all panels:

opt in to SSE-triggered refresh
from imperal_sdk import Extension, ui

ext = Extension("my-ext", display_name="My Extension", description="...")


@ext.panel(
    "sidebar",
    slot="left",
    title="Notes",
    refresh="on_event:notes.created,notes.updated,notes.deleted",
)
async def sidebar(ctx: object, **kwargs: object) -> object:
    return ui.Text("sidebar content")

Why.: the SSE consumer fires refetchRef.current?.() (= refreshAll()) on event match — it never reads data.panels.: Path A does read refresh_panels per panel. The per-panel scoping is Path A only. See refresh modes in the concept page.


7. Multi-tab right panel: switching between tabs does nothing

Symptom. Your extension declares two @ext.panel handlers both with slot="right". You expected the host to render a tab strip between them. Clicking a tab UI element does nothing, or the second panel never renders.

Cause. The platform host does not implement a tab strip for the right slot. There is one rightPanel React state value per extension page — a single UINode. Setting right_panel.tabs in the extension manifest does not cause the host to render a tab switcher. Multiple panels targeting slot="right" are handled via slot replacement: each the panel call unconditionally replaces the previous right-panel content (Q1 replace semantics).

The tasks extension CHANGELOG records an "interim right-slot composite experiment committed and reverted same-day" (v2.0.6) — confirming this was tried and abandoned.

Fix. Use a single @ext.panel handler that branches internally on an active_tab kwarg. Render a ui.Tabs component (or equivalent) inside the panel UINode tree. Use ui.Call(...) on tab click to refresh the same panel with a new active_tab value:

multi-tab right panel — single handler with kwarg branching
from imperal_sdk import Extension, ui
from imperal_sdk.ui.base import UINode

ext = Extension("my-ext", display_name="My Extension", description="...")

_DETAIL_DATA = {"item-1": {"info": "Info for item 1", "history": "History for item 1"}}


@ext.panel("details", slot="right", title="Details")
async def details(
    ctx: object,
    item_id: str = "",
    active_tab: str = "info",
    **kwargs: object,
) -> object:
    if not item_id:
        return ui.Empty(message="Select an item", icon="ℹ️")
    # Tab bar rendered inside the UINode tree — host provides no tab strip.
    tab_bar = ui.Stack(direction="h", children=[
        ui.Button(
            label="Info",
            on_click=ui.Call("__panel__details", item_id=item_id, active_tab="info"),
            variant="primary" if active_tab == "info" else "ghost",
        ),
        ui.Button(
            label="History",
            on_click=ui.Call("__panel__details", item_id=item_id, active_tab="history"),
            variant="primary" if active_tab == "history" else "ghost",
        ),
    ])
    data = _DETAIL_DATA.get(item_id, {})
    content: UINode
    if active_tab == "history":
        content = ui.Text(str(data.get("history", "No history")))
    else:
        content = ui.Text(str(data.get("info", "No info")))
    return ui.Stack(children=[tab_bar, content])

Why. the canonical pattern for switching center or right content is a single @ext.panel handler that multiplexes via kwargs, not multiple @ext.panel declarations. Tab UI is rendered inside the UINode tree by the extension, not by the host shell. See Layout 3 — Multi-tab right panel for the full walkthrough.


8. slot="overlay" renders nothing

Symptom. You declare @ext.panel("my_overlay", slot="overlay", ...). The SDK accepts the declaration without error. At runtime, the panel never renders — the center area stays empty and no call to your handler is made.

Cause. slot="overlay" is accepted by the SDK validator (it is in ALLOWED_PANEL_SLOTS at ) but the frontend has no render path for it. A full-text search of the panel host frontend source for the string "overlay" returned zero matches in panel routing, slot dispatch, or rendering code. The center-overlay behavior you see in the Notes extension is not driven by slot="overlay" — it is driven by the hardcoded the center-overlay routing rule function in that matches specific panel_id strings:

the center-overlay routing rule — hardcoded allowlist (frontend)
# This is frontend logic, not derived from your slot= declaration.
# panel_id "editor" + note_id present  → center overlay
# panel_id "compose"                   → center overlay
# panel_id "email_viewer" + message_id → center overlay
# Everything else → left or right slot (never overlay)

Panels with slot="bottom" and slot="chat-sidebar" are in the same situation: SDK-valid, frontend-dead.

Fix. To get center-overlay style rendering, use slot="center" and name your panel "editor" (passing note_id=), "compose", or "email_viewer" (passing message_id=) — the three panel_ids currently in the the center-overlay routing rule allowlist. For plain mounted panels, use slot="left", slot="right", or slot="center" (for a non-overlay center panel):

center overlay — use slot='center' + supported panel_id
from imperal_sdk import Extension, ui

ext = Extension("my-ext", display_name="My Extension", description="...")

_NOTES_C8 = {"note-1": "Note body here"}


# Works as center overlay because panel_id == "editor" and note_id is passed.
@ext.panel("editor", slot="center", center_overlay=True, title="Editor")
async def editor(ctx: object, note_id: str = "", **kwargs: object) -> object:
    if not note_id:
        return ui.Empty(message="Select a note", icon="📄")
    body = _NOTES_C8.get(note_id, "")
    return ui.Stack(children=[ui.Text(body)])

If your extension needs a center overlay for a panel_id that is not in the allowlist, a frontend change to the panel host is required — file a platform ticket. There is no workaround at the SDK level.

Why. : slot="overlay" is an SDK-level accepted value with zero frontend implementation. The SDK docs correctly note it is "reserved for future host work." The actual overlay mechanism (the center-overlay routing rule) is hardcoded to specific panel_id strings with no extensibility hook. See decoration — the slot allowlist for the full slot status table.


9. @panels.sidebar / @panels.editor decorators not found

Symptom. Your code uses @panels.sidebar, @panels.settings, @panels.editor, or @panels.action. Python raises AttributeError: module 'imperal_sdk' has no attribute 'panels' (or similar). Alternatively, a slot value such as "left-top", "left-bottom", "center-tab", "settings-tab", or "modal" raises ValueError at decoration time.

Cause. That API never shipped. An older version of docs.imperal.io/concepts/panels.mdx documented a fictional @panels.X decorator namespace and slot names that do not exist in the SDK. The real decorator is @ext.panel(panel_id, slot=..., title=...) (audit §1.1, cross-checked against ). The slot allowlist is {center, left, right, overlay, bottom, chat-sidebar} — nothing else (validated at decoration time via a frozenset at ).

Common mistranslations from the old docs:

Old (fictional)Correct replacement
@panels.sidebar@ext.panel("sidebar", slot="left", title="...")
@panels.editor@ext.panel("editor", slot="center", center_overlay=True, title="...")
@panels.settings@ext.panel("settings", slot="center", center_overlay=True, title="Settings") — or a dedicated settings page outside the panel system
@panels.actionNo equivalent; actions use ui.Call(...) bound to a button or list item
slot="left-top"slot="left"
slot="left-bottom"slot="left" (one left panel per extension; use kwarg branching for sub-sections)
slot="center-tab"slot="center" (host renders no tab strip; implement tabs inside UINode — see case 7)
slot="settings-tab"No equivalent; ship a center panel from a dedicated settings extension or use the Developer Portal settings-form API
slot="modal"No equivalent; redesign as slot="center" with panel_id="editor" + close affordance

Fix. Convert all @panels.<type> decorators to @ext.panel:

# This code does not work. @panels namespace does not exist.
# from imperal_sdk import ui
#
# @panels.sidebar
# async def my_sidebar(ctx, **kwargs):
#     return {"type": "list", "items": [...]}
#
# @panels.editor(slot="left-bottom")
# async def my_editor(ctx, **kwargs):
#     return {"type": "rich_editor", "value": "..."}
after — real SDK
from imperal_sdk import Extension, ui

ext = Extension("my-ext", display_name="My Extension", description="...")


@ext.panel("sidebar", slot="left", title="My Sidebar")
async def my_sidebar(ctx: object, **kwargs: object) -> object:
    return ui.List(items=[ui.ListItem(id="1", title="Item 1")])


@ext.panel("editor", slot="center", center_overlay=True, title="My Editor")
async def my_editor(ctx: object, item_id: str = "", **kwargs: object) -> object:
    if not item_id:
        return ui.Empty(message="Select an item", icon="📄")
    return ui.RichEditor(content="Content here")

Also update any bare dict return shapes — the SDK wrapper at checks hasattr(result, 'to_dict'). Bare dicts pass through but lose type-checking. Return ui.* UINode objects.

Why. Audit §1.1 in the design spec documents this as a "Blocker" finding: the previous concepts/panels.mdx described an API that was never shipped. This case is retained for approximately six months until search engines re-index the new content. See decoration — @ext.panel for the real decorator signature.


10. refresh_seconds=N decorator kwarg silently ignored

Symptom. You decorate a panel with refresh_seconds=10 (or refresh_seconds=30) expecting the host to poll your handler every N seconds. The panel never auto-refreshes.

Cause. Two things are wrong simultaneously:

  1. Wrong parameter name. The decorator parameter is refresh= (a string), not refresh_seconds= (an integer). refresh_seconds=10 is stored as a raw key in the _panels[panel_id] dict via **kwargs and never read by any downstream pipeline stage.

  2. Even the correct format is silently dropped. The string format "interval:10s" is accepted by the frontend (the panel host parses interval:Ns into a setInterval) but the web-kernel publisher only propagates on_event: prefixed values to config.refresh. An @ext.panel(refresh="interval:10s") declaration is stored in _panels at decoration time but the platform panel-config publisher only copies on_event: strings — "interval:10s" is silently dropped and never reaches the frontend ShellConfig.refresh field.

Specifically, the platform panel-config publisher does:

# the platform panel-config publisher:42-47 (web-kernel source — shown for reference, not SDK code)
#
# refresh = meta.get("refresh", "manual")
# if refresh.startswith("on_event:"):
#     events = refresh.replace("on_event:", "").split(",")
#     refresh_events.extend(e.strip() for e in events if e.strip())
#
# "interval:10s" → does not start with "on_event:" → falls through → nothing written

Fix. Use refresh="on_event:scope.action" and emit the matching SSE event from your write functions. This is the supported SDK-to-frontend pipeline for automatic panel refresh:

on_event refresh — the supported pattern
from imperal_sdk import Extension, ui

ext = Extension("my-ext", display_name="My Extension", description="...")

_DASHBOARD_DATA = ["item-1", "item-2", "item-3"]  # replace with your real fetch


@ext.panel(
    "dashboard",
    slot="center",
    center_overlay=True,
    title="Dashboard",
    refresh="on_event:myapp.data_updated",  # re-fetches when web-kernel emits this event
)
async def dashboard(ctx: object, **kwargs: object) -> object:
    data = _DASHBOARD_DATA
    return ui.Text(f"Total items: {len(data)}")

If you genuinely need time-based polling, interval:Ns must be written directly to the auth gateway config.ui.refresh field via the admin API — the SDK refresh= parameter cannot do it today. File a web-kernel ticket for SDK-to-publisher interval support.

refresh_seconds=N as a kwarg is doubly ignored: wrong parameter name AND wrong value type. refresh="interval:10s" is silently dropped by the publisher. Do not use either form.

Why.: self._panels[panel_id] = {..., **kwargs} — all kwargs stored blindly.: only on_event: strings are propagated to config.refresh; all other refresh= values produce no auto-refresh. The prior docs page claimed refresh_seconds=10 causes the "React app to re-fetch every 10 seconds" — this was fiction. See refresh modes.


What's next

On this page