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:
| # | Symptom | Audit anchor |
|---|---|---|
| 1 | Center overlay does not open even though ui.Call("__panel__editor", note_id=...) fires | Q1, Q2, Q3, Q4 |
| 2 | Panel refreshes instead of replacing | Q1 |
| 3 | auto_action does not fire after the user navigates away and back | Q4 |
| 4 | Declared kwarg appears to dispatch differently than undeclared | Q5 |
| 5 | Batch discovery consumes the slot you wanted auto_action to claim | Q2, Q3 |
| 6 | refresh_panels does not pick up fresh data — or refreshes more panels than expected | Q6, Q13 |
| 7 | Multi-tab right panel: switching between tabs does nothing | Q9 |
| 8 | slot="overlay" renders nothing | Q10, Q4 |
| 9 | @panels.sidebar / @panels.editor decorators not found | §1.1 |
| 10 | refresh_seconds=N decorator kwarg silently ignored | Q11, 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:
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 unconditionallyFix. 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:
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 rootWhy. 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:
- Your handler branches on
paramsbut the relevant kwarg was not included in theui.Call, so the handler returned the same content as before. - The new
ui.Callarrived 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). - The handler returned
Noneor a falsyd.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:
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:
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):
discovering === falsethe discovery-once guard === false(only true at fresh discovery start)centerOverlay === nullleftPanelis non-nullleftPanel.props.auto_actionhasaction === 'call'and afunctionfield
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:
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 rootIf 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:
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 mountCall 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.
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:
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 rootUse 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 None → result is None → no ui key → the left-slot mount not called → slot stays null → auto_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).
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:
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:
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:
# 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):
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.action | No 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": "..."}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:
-
Wrong parameter name. The decorator parameter is
refresh=(a string), notrefresh_seconds=(an integer).refresh_seconds=10is stored as a raw key in the_panels[panel_id]dict via**kwargsand never read by any downstream pipeline stage. -
Even the correct format is silently dropped. The string format
"interval:10s"is accepted by the frontend (the panel host parsesinterval:Nsinto asetInterval) but the web-kernel publisher only propagateson_event:prefixed values toconfig.refresh. An@ext.panel(refresh="interval:10s")declaration is stored in_panelsat decoration time but the platform panel-config publisher only copieson_event:strings —"interval:10s"is silently dropped and never reaches the frontendShellConfig.refreshfield.
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 writtenFix. 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:
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
Mental model
Slot ownership, lifecycle, batch discovery, and dispatch sources — the foundation behind every fix above.
Build a panel layout
Four canonical layouts end to end: master-detail, center-overlay-editor, multi-tab-right, hub-only-center.
Recipes
Copy-paste minimal patterns for each layout.
@ext.panel reference
Every kwarg, every accepted value, every default.