Panels
UI surfaces an extension renders inside the Imperal Panel app
A panel is a UI region your extension renders inside the Imperal Panel app. The user sees the panel; the LLM does not. Panels coexist with chat โ chat is always present in its own slot โ and contribute persistent surfaces (sidebars, editors, dashboards) that the user interacts with directly.
Which decorator do I write?
Pick by intent, not by surface:
- Need the user to see something on screen โ
@ext.panel(this page). - Need the LLM to know something continuously โ
@ext.skeleton(skeletons). - Need the LLM to do something on demand โ
@chat.function(chat functions). - Want the LLM to recall the verbatim result of a recent tool call โ nothing โ the fact-ledger does this automatically.
These are orthogonal primitives. A mature extension uses all four together: a skeleton for awareness, chat functions for actions, the automatic fact-ledger for cross-turn grounding, and one or more panels for the visual UI. See Context channels for how the three LLM-facing primitives coordinate.
Building UI? Use panels. Not skeletons.
@ext.panel is the only correct decorator for UI surfaces โ sidebars, dashboards, editors, settings forms, anything the user sees. @ext.skeleton is exclusively a data probe consumed by the LLM (Webbee's awareness, behind the scenes); it has no rendering side and no panel slot. If you are building anything visible, you want @ext.panel. Inside the panel handler, fetch user state via ctx.cache (short-lived) or ctx.store (persistent) โ ctx.skeleton.get() is restricted to @ext.skeleton handlers by federal invariant I-SKELETON-LLM-ONLY and raises SkeletonAccessForbidden from any other context.
This page explains the mental model: what a slot is, who owns it, when each lifecycle event fires, and the four sources that can update a panel. Once you have the model, the task-oriented guides and copy-paste recipes are short.
Decoration โ @ext.panel
Every panel is one Python function decorated with @ext.panel. The decorator signature (verified against imperal-sdk source at ):
from imperal_sdk import Extension, ui
ext = Extension("my-ext", display_name="My Extension", description="...")
@ext.panel(
"sidebar", # panel_id โ unique within your extension; required
slot="left", # one of the 6 allowed values; see table below
title="Sidebar", # shown in slot chrome; empty string = panel_id
icon="๐", # Lucide icon name; empty string = no icon
refresh="manual", # refresh strategy; see Refresh modes section
default_width=280, # optional: initial width in px (left/right panels only)
min_width=200, # optional: minimum resizable width in px
max_width=500, # optional: maximum resizable width in px
)
async def sidebar(ctx: object, **kwargs: object) -> object:
return ui.Text("Hello from sidebar")The decorator registers a synthetic tool named __panel__{panel_id} (e.g., __panel__sidebar). The host calls this tool via the /call endpoint when it needs to render the panel.
The 6 allowed slot values
The slot argument is validated at decoration time against ALLOWED_PANEL_SLOTS (imperal_sdk/types/contributions.py). Any other value raises ValueError immediately. Frontend rendering reality โ what the Imperal Panel host (usePanelDiscovery.ts + ExtensionShell.tsx) actually does with each slot:
| Slot value | Description | How it renders | Auto-fetched on init? |
|---|---|---|---|
"left" | Left sidebar โ navigation, item lists | Permanent slot. Fetched at session-init via batch discovery; persistent until user navigates away. | โ yes |
"right" | Right sidebar โ context cards, dashboards | Permanent slot. Same as left โ fetched at session-init, persistent. | โ yes |
"center" | Main content area โ opens over the chat region | Center-overlay surface. Declarative activation in v4.1.8+: pass center_overlay=True to @ext.panel(slot="center", center_overlay=True, ...) and the host fetches it at session-init alongside left/right. When active, chat shrinks to a 380 px right rail. | โ
yes (when center_overlay=True) |
"overlay" | Reserved โ accepted by SDK validator | Never rendered. No frontend code path. | โ |
"bottom" | Reserved โ accepted by SDK validator | Never rendered. No frontend code path. | โ |
"chat-sidebar" | Reserved โ accepted by SDK validator | Never rendered. No frontend code path. | โ |
The "main" value was removed in SDK 3.4.0 โ use "center" instead. Passing "main" raises ValueError.
How slot="center" actually activates
Federal contract since SDK v4.1.8: declare center_overlay=True and the panel host wires the overlay declaratively โ no hardcoded TS allowlist edits, no boilerplate auto_action for the common case:
@ext.panel(
"workshop",
slot="center",
title="Automation Workshop",
icon="๐",
center_overlay=True, # โ declarative; published into unified_config
)
async def workshop_panel(ctx, **kwargs):
return ui.Stack([...])The Dev Portal panel-config sync writes panels.center.center_overlay: true into the auth-gw's unified_config at deploy time. The Imperal Panel host reads config.panels.center.panel_id + center_overlay at session-init and fetches the panel alongside left/right in a single batch round-trip. Layout flips to [overlay flex-1] [chat 380 px] automatically when a non-empty UINode renders.
When the overlay is dismissed (via shouldClearOverlay panel_id navigation, or by returning None from a refresh), the centerOverlay state clears and the layout returns to chat 100%.
Declarative center-overlay shipped in v4.1.8 (current SDK v4.2.14).
The frontend reads panels.center.center_overlay from unified_config and routes via isCenterOverlay(pid, p, panelsConfig):
function isCenterOverlay(
pid: string,
p: Record<string, unknown>,
panelsConfig?: { center?: { panel_id?: string; center_overlay?: boolean } },
): boolean {
// v4.1.8+: declarative path โ first source of truth.
if (panelsConfig?.center?.panel_id === pid &&
panelsConfig.center.center_overlay === true) return true;
// Legacy fallback for pre-v4.1.8 extensions (mail, notes) โ to be removed.
return (pid === 'email_viewer' && !!p.message_id)
|| pid === 'compose'
|| (pid === 'editor' && !!p.note_id)
|| pid === 'workshop';
}Adding a new center-overlay extension is now zero-config: declare @ext.panel(slot="center", center_overlay=True), deploy via panel.imperal.io/developer, and the web-kernel publishes the flag into unified_config โ the frontend picks it up automatically.
slot="overlay" / slot="bottom" / slot="chat-sidebar" are still accepted by the SDK validator at decoration time but the frontend has no render path for them. If you declare @ext.panel("my_viewer", slot="overlay"), the panel is registered fine but will never be rendered by the current host. These slot values remain reserved for future host work.
Host-recognized **kwargs
Only three **kwargs travel end-to-end from the SDK decorator through the web-kernel publisher to the frontend (verified against and types.ts:PanelConfig):
| Key | Type | Default (left / right) | Effect |
|---|---|---|---|
default_width | int (px) | 300 / 280 | Initial panel width; converted to % of 1400 px viewport |
min_width | int (px) | 200 / 200 | Minimum width when user drags the resize handle |
max_width | int (px) | 500 / 500 | Maximum width when user drags the resize handle |
These keys are silently ignored for slot="center" panels (the web-kernel publisher skips panels whose slot is not "left" or "right"). All other **kwargs are stored in the SDK's internal _panels dict but never forwarded to the frontend.
Slot ownership โ who renders what
Slots at session init (batch discovery)
When a user navigates to your extension page, the host fires a single the platform batch-discovery endpoint POST containing one call for each configured slot (Q2 โ verified-stable). The call set is determined by the extension's config stored in the auth gateway: config.panels.left.panel_id and config.panels.right.panel_id. Discovery fires exactly once per extId:leftId:rightId combination per browser session.
What this means for you:
- Your
slot="left"panel handler is called at init withparams: {}. - Your
slot="right"panel handler (if configured) is called at init withparams: {}. - Your
slot="center"panel handler is not called at init โ center panels are only reached via explicitui.Call(...)action dispatch orauto_action. - If your handler returns
Noneor no UINode during discovery, the slot remains empty andauto_actioncannot fire (see below).
Replace semantics โ no stacking
When a ui.Call("__panel__X", ...) action arrives at a slot that already has a panel mounted, the slot is replaced โ not stacked, not tabbed. The previous panel's React tree unmounts cleanly and the new UINode renders in its place (Q1 โ verified-stable, source: ).
The one exception is pagination: if a response includes a cursor field, the panel call uses append=true mode and merges the new list items into the existing UINode via mergeListItems. This is pagination only, not stacking.
ui.Empty() โ the canonical empty-state pattern
When a handler has nothing to show (e.g., your editor panel is called at batch discovery before any item is selected), do not return None. Instead return ui.Empty(message=..., icon=...):
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", icon="โ๏ธ")
async def editor(ctx: object, note_id: str = "", **kwargs: object) -> object:
if not note_id:
# Canonical: renders a visible placeholder, keeps leftPanel non-null,
# allows auto_action to fire. Do NOT return None here.
return ui.Empty(message="Select a note to edit", icon="๐")
# ... fetch and render the note
return ui.Text(f"Editing note {note_id}")Why ui.Empty() over return None: When the SDK wrapper receives None from your handler, it passes None through with no ui key in the response. The host's batch discovery handler checks results[idx]?.ui โ if falsy, the left-slot mount / the right-slot mount is never called and the slot stays null. Because leftPanel is null, the auto_action useEffect guard blocks and the center overlay never auto-opens (Q3 โ verified-stable).
ui.Empty() returns a proper UINode, keeps the slot non-null, and gives the user a visible placeholder rather than a blank region.
| Return value | Slot state after call | auto_action fires? | User sees |
|---|---|---|---|
ui.Empty(message=...) | Non-null (placeholder rendered) | Yes (if conditions met) | Placeholder card |
ui.Error(message=...) | Non-null (error card rendered) | Yes | Error card |
None | Stays null (or unchanged for non-discovery calls) | No | Blank region |
Slot ownership rules summary
- Replace: each the panel call for a slot replaces the previous UINode unconditionally.
- Params accumulate: the host's the accumulated panel params accumulates params per panel_id. Re-fetches merge
{}into the accumulated params, preserving user context (active folder, scroll state). - Last write wins: concurrent in-flight calls to the same slot have no abort/cancel โ whichever the left-slot mount/the right-slot mount resolves last is what the user sees.
- No cross-slot inheritance: params for
"sidebar"and params for"editor"are tracked separately bypanel_id, not by slot.
Lifecycle โ declaration โ mount โ unmount
The following walkthrough traces one complete session from page load to navigation away.
SESSION INIT User navigates to your extension page.
โ
โผ
BATCH DISCOVERY Host fires one /batch POST:
{ calls: [
{ function: "__panel__sidebar", params: {} },
{ function: "__panel__details", params: {} }
] }
Your handlers run. Results populate leftPanel / rightPanel.
โ
โผ
AUTO_ACTION CHECK If leftPanel.props.auto_action exists and centerOverlay is null,
the host fires the action once. E.g., opens the editor for the
most-recent item.
โ
โผ
INITIAL RENDER User sees: left sidebar + (optional) center panel.
Right panel visible if rightId is configured.
โ
โผ
USER CLICKS User clicks a list item. on_click = ui.Call("__panel__editor", note_id="abc")
Host calls __panel__editor with { note_id: "abc" }.
the center-overlay routing rule("editor", { note_id: "abc" }) โ true
the center-overlay mount(result.ui) โ center overlay opens
โ
โผ
USER WRITES User edits content. A button fires ui.Call("save_note", ...).
Web-kernel runs save_note @chat.function.
ActionResult.refresh_panels = ["sidebar"]
Host re-fetches sidebar with accumulated params (Path A).
โ
โผ
USER NAVIGATES User clicks "Back to Notes" = ui.Call("__panel__sidebar", view="")
the overlay clear-on-navigate rule("sidebar", {}) โ true
the center-overlay mount(null) โ overlay closes
the panel call โ sidebar refreshed
โ
โผ
UNMOUNT User navigates away from extension page.
React unmounts. discoveredRef / the accumulated panel params / the discovery-once guard reset
on next navigation back to the extension.Step-by-step breakdown
Step 1 โ SESSION INIT. The user opens the extension page. ExtensionPage fetches config from /api/extensions/{extId}/config, which returns config.panels.left.panel_id (e.g., "sidebar") and optionally config.panels.right.panel_id. These IDs are passed to the panel discovery hook(extId, config).
Step 2 โ BATCH DISCOVERY. the panel discovery hook fires a single /batch POST containing one entry per configured slot. Both calls use params: {}. The guard the discovery key guard ensures this fires at most once per extId:leftId:rightId combination โ navigating away and back resets it on remount.
Step 3 โ INITIAL RENDER. The batch response populates leftPanel and/or rightPanel. If a handler returned no UINode (falsy result.ui), the slot stays null and discovering becomes false.
Step 4 โ AUTO_ACTION CHECK. After discovery completes (discovering === false), a useEffect reads leftPanel.props.auto_action. If the value is a UIAction with action === 'call' and a function field, the host dispatches it once (guarded by the discovery-once guard). This fires center-overlay panels for extensions that follow the master-detail pattern.
Step 5 โ USER INTERACTION. Every on_click, on_change, or similar prop in your UINode tree produces a ui.Call(...) action. The host dispatches the call to /call, receives the new UINode, and routes it: overlay-matching panel_ids go to the center-overlay mount, left/right panel_ids go to the panel call.
Step 6 โ WRITE + REFRESH. When a @chat.function returns ActionResult(refresh_panels=["sidebar"]), the host re-fetches the listed panel_ids using accumulated params from the accumulated panel params. See ยง Four ways a panel updates for the full two-path behavior.
Step 7 โ UNMOUNT. On navigation away, React unmounts the extension page. discoveredRef, the accumulated panel params, the discovery-once guard, and the discovery key guard are reset. The next visit triggers a fresh batch discovery.
Four ways a panel updates
1. User click via ui.Call("__panel__X", ...)
The most direct dispatch: a UINode element's on_click (or on_change, on_drop, etc.) is set to a ui.Call(...) action. When the user triggers the element, the host calls __panel__{X} with the kwargs you supplied:
from imperal_sdk import Extension, ui
ext = Extension("my-ext", display_name="My Extension", description="...")
@ext.panel("sidebar", slot="left", title="Sidebar")
async def sidebar(ctx: object, folder_id: str = "", **kwargs: object) -> object:
items = [
ui.ListItem(
id="folder-1",
title="Work",
on_click=ui.Call("__panel__sidebar", folder_id="folder-1"),
),
ui.ListItem(
id="note-1",
title="Meeting notes",
on_click=ui.Call("__panel__editor", note_id="note-1"),
),
]
return ui.List(items=items)Declared vs undeclared kwargs are dispatched identically (Q5 โ verified-stable, candidate a). The web-kernel's the web-kernel direct-call dispatch activity spreads all incoming params as **params to __panel__* handlers. There is no routing split based on whether a kwarg matches a declared parameter name in your handler. The developer-observed "declared params take one path, undeclared another" was a batch-discovery cache symptom (Q2): at discovery time params: {} is sent; at action time the accumulated the accumulated panel params context is merged in. The web-kernel never inspects param names against your handler signature before dispatching.
2. ActionResult.refresh_panels=[...]
After a @chat.function write action, return refresh_panels in your ActionResult to trigger a panel re-fetch (Q6 + Q13 โ verified-stable):
from imperal_sdk import Extension, ActionResult
ext = Extension("my-ext", display_name="My Extension", description="...")
@ext.panel("sidebar", slot="left", title="Sidebar")
async def sidebar(ctx: object, **kwargs: object) -> object:
return object() # placeholder โ real implementation returns UINode
async def save_note(ctx: object, note_id: str, body: str) -> ActionResult: # type: ignore[type-arg]
# ... persist the note ...
return ActionResult.success(
data={"note_id": note_id},
summary="Note saved.",
refresh_panels=["sidebar"], # bare panel_id, NOT "__panel__sidebar"
)Values are bare panel_id strings โ not prefixed with __panel__. The host prepends the prefix internally when it calls the endpoint.
There are two code paths with different scoping behavior:
Path A โ panel-initiated (HTTP response): When a panel button triggers a @chat.function via the direct /call endpoint, the host reads refresh_panels from the HTTP response and calls the panel call for each listed ID. This is targeted: only the listed panels are re-fetched. Each re-fetch merges {} with the accumulated the accumulated panel params, preserving user context.
Path B โ chat-initiated (SSE push): When a chat-driven @chat.function returns refresh_panels, the web-kernel publishes a panel_refresh SSE event with data.panels: [...]. However, the frontend SSE consumer does not read data.panels โ it fires refreshAll() unconditionally, re-fetching ALL discovered panels regardless of which panel_ids were listed. The panels list in the SSE event payload is dead data on the consumer side.
Additionally, Path B is only active if your extension's config.refresh includes on_event:... matching the panel_refresh event. Extensions using the default refresh="manual" receive the SSE event but eventMatchers is null โ the re-fetch is silently dropped. Chat-initiated refresh_panels does not reach manual-refresh extensions.
Path B gap (Q6 SSE). If your user asks Webbee in chat to create a note and your create_note function returns refresh_panels=["sidebar"], the sidebar will only refresh automatically if your sidebar panel's refresh= param includes on_event: matching the panel_refresh event for your extension's scope. For extensions with refresh="manual" (the default), only panel-button clicks (Path A) trigger targeted panel refreshes from ActionResult. See ยง Refresh modes for the full refresh configuration guide.
3. auto_action prop on the root UINode
auto_action lets your left panel's initial render automatically open a center panel โ for example, opening the most-recent note when the user first loads the extension (Q4 โ verified-unstable, see callout below).
from imperal_sdk import Extension, ui
ext = Extension("my-ext", display_name="My Extension", description="...")
@ext.panel("sidebar", slot="left", title="Notes")
async def sidebar(ctx: object, active_note_id: str = "", **kwargs: object) -> object:
all_notes = [{"id": "note-1", "title": "First note"}] # from your API
items = [
ui.ListItem(
id=n["id"], title=n["title"],
on_click=ui.Call("__panel__editor", note_id=n["id"]),
)
for n in all_notes
]
root = ui.Stack(children=[ui.List(items=items)])
# Auto-open the most-recent note on first load, but only if no note
# is already active (avoid re-triggering on sidebar refreshes).
if not active_note_id and all_notes:
root.props["auto_action"] = ui.Call(
"__panel__editor", note_id=all_notes[0]["id"]
)
return rootContract rules (from ):
auto_actionis read only fromleftPanel.props.auto_actionโ not fromrightPanel,centerOverlay, or any nested UINode.- It fires once per extension page session (guarded by the discovery-once guard). Navigating away and back resets the guard and fires again on the next discovery.
- It fires only when: discovery is complete, no overlay is already open,
leftPanelis non-null, and the action hasaction === 'call'with afunctionfield. - The format is the
UIActionobject produced byui.Call(...).
v4.1.8+: center-overlay routing is declarative.
auto_action is no longer the only way to open a center panel. With @ext.panel(slot="center", center_overlay=True), the host fetches the panel at session-init alongside left/right in the same batch round-trip โ no auto_action boilerplate needed for the common case.
auto_action is still useful for scoped first-mount opens โ e.g. opening the most-recent note (note_id="abc") on first mount instead of letting the user pick:
root.props["auto_action"] = ui.Call("__panel__editor", note_id=most_recent_id)The host's center-overlay routing now reads panels.center.panel_id + center_overlay from unified_config first, then falls back to the legacy hardcoded allowlist (compose, email_viewer+message_id, editor+note_id, workshop) for pre-v4.1.8 extensions that haven't redeployed yet. Any panel_id in your auto_action that matches either path opens the overlay; otherwise the call is routed to the right slot.
4. Push updates via SSE
The panel host connects a module-level EventSource to the platform event stream (one connection per browser tab, shared across all useEvent subscribers). the panel host shell subscribes to all events and matches them against the extension's config.refresh string (Q7 โ verified-stable).
When a matching SSE event arrives, the panel host shell calls refreshAll(), which re-fetches all panels in discoveredRef. The re-fetch uses accumulated the accumulated panel params params (user context is preserved).
To enable SSE-driven refresh, declare the events on your panel:
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_sse(ctx: object, **kwargs: object) -> object:
return ui.Text("Sidebar content")The on_event: string lists scope.action pairs. When the web-kernel emits an SSE event with matching scope+action, the host fires refreshAll(). The matching is three-way: qualified (scope.action), bare action, or bare scope.
SSE refresh re-fetches ALL discovered panels, not only the one that declared the event. There is no per-panel event routing โ config.refresh is a single string shared across the entire extension.
Panels vs widgets vs trays
The Imperal SDK defines three panel-like decorators. All three register synthetic tools under the hood (Q14 โ verified-stable):
@ext.panel โ persistent slot UI
Use when: you are building a sidebar, editor, viewer, or any other UI area that the user interacts with continuously across a session.
@ext.panel registers __panel__{panel_id} and stores rich slot metadata (slot, title, icon, refresh, width kwargs) in ext._panels. The web-kernel publisher reads ext._panels to configure the extension page layout in the auth gateway. The panel host calls the handler at batch discovery (left/right slots) or on explicit ui.Call(...) action.
This is the primary UI primitive for extension development. All three Dimasickky reference extensions (notes, sql-db, tasks) use @ext.panel exclusively for their UI surfaces.
@ext.widget โ dashboard injection point
Use when: you want to embed a summary card or stat tile into the platform's dashboard grid (e.g., a "3 overdue tasks" counter).
@ext.widget registers __widget__{widget_id} with a default slot="dashboard.stats". Unlike @ext.panel, it stores no metadata in ext._panels โ widget metadata is discovered through a separate manifest mechanism. The dashboard host reads widget registrations; the panel discovery hook does not.
Production usage in this workspace: zero. @ext.widget is SDK-defined and callable but no production or reference extension uses it. Widget behavior and the dashboard host that renders widgets are not covered in this bible โ they deserve a separate dedicated guide.
@ext.tray โ top-bar notification badge
Use when: you want a persistent notification count badge in the platform's top navigation bar (e.g., an unread-mail counter with a dropdown).
@ext.tray registers __tray__{tray_id} and stores a TrayDef (tray_id, icon, tooltip) in ext._tray. The tray bar host reads ext.tray_items at startup to register icons. Tray handlers return a UINode containing a badge and optional dropdown panel.
Production usage in this workspace: zero. @ext.tray is SDK-defined but no production or reference extension uses it. Tray behavior deserves its own future guide.
Summary table
| Decorator | Renders in | Fired by | Pick when... |
|---|---|---|---|
@ext.panel | Left/right sidebar, center area of extension page | Batch discovery + explicit ui.Call(...) | Building persistent interactive UI (sidebar, editor, viewer) |
@ext.widget | Platform dashboard grid | Dashboard host on-demand | Embedding a stat tile or summary card in the dashboard |
@ext.tray | Top navigation bar | Tray bar host (polled / event-driven) | Showing a notification count badge in the top bar |
Refresh modes โ refresh=
The refresh= parameter on @ext.panel controls automatic panel re-fetching driven by SSE events or time intervals (Q12 โ verified-stable).
from imperal_sdk import Extension, ui
ext = Extension("my-ext", display_name="My Extension", description="...")
# Manual (default) โ no auto-refresh; panel only updates on explicit ui.Call dispatch
@ext.panel("sidebar_manual", slot="left", refresh="manual")
async def sidebar_manual(ctx: object, **kwargs: object) -> object:
return ui.Text("Manual refresh only")
# Event-driven โ re-fetch when web-kernel emits matching SSE events
@ext.panel("sidebar_events", slot="left", refresh="on_event:notes.created,notes.updated")
async def sidebar_events(ctx: object, **kwargs: object) -> object:
return ui.Text("Refreshes on notes.created and notes.updated events")How each value works (end-to-end)
refresh= value | Frontend behavior | Reaches config.refresh? |
|---|---|---|
"manual" (default) | No auto-refresh. eventMatchers is null, interval effect returns early. | Not written โ absence treated as manual. |
"on_event:scope.action[,...]" | SSE subscriber re-fetches all discovered panels on any matching event. | Yes โ web-kernel publisher collects all on_event: strings from all left/right panels and merges them into a single deduplicated config.refresh. |
"interval:Ns" | setInterval re-fetches all discovered panels every N seconds (if written directly to the auth gateway). | No โ silently dropped by the web-kernel publisher (see callout). |
refresh="interval:Ns" is silently dropped by the web-kernel publisher.
The web-kernel's the platform panel-config publisher only propagates on_event: prefixed values to config.refresh. A panel decorated with @ext.panel("sidebar", refresh="interval:30s") will have the string stored in the SDK's internal _panels dict, but the platform panel-config publisher checks only for on_event: prefix โ interval strings are never written to the auth gateway.
The old documentation's claim of refresh_seconds=10 as a decorator kwarg is doubly wrong: (a) the parameter name is refresh=, not refresh_seconds=; (b) the correct string format is "interval:10s", not an integer; (c) even the correct string format "interval:10s" is silently dropped by the publisher. Do not use refresh_seconds=N.
If you need interval polling, you must write config.refresh = "interval:30s" directly to the auth gateway via the admin API โ it cannot be configured through the SDK decorator today.
Multi-panel extensions and refresh scope
For extensions with both a left and right panel, the publisher merges all on_event: strings across all left/right panels into a single deduplicated config.refresh string. The frontend fires refreshAll() โ re-fetching ALL discovered panels โ when any event in the merged set matches. There is no per-panel event routing: if your sidebar declares on_event:notes.created and your details panel declares on_event:notes.updated, both panels are re-fetched when either event fires.
What's next
Build a panel layout
Master-detail, center-overlay-editor, multi-tab-right, hub-only-center โ task-oriented walkthroughs for the four canonical layouts.
Recipes
Copy-paste minimal patterns: master-detail, center overlay, auto-open on load, refresh after write.
@ext.panel reference
Every kwarg, every accepted value, every default โ anchored against extension.py source.
ui actions reference
ui.Call / ui.Navigate / ui.Send / ui.Open signatures + auto_action prop format.
Troubleshooting
My overlay does not open / my panel refreshes instead of replaces / SSE refresh not firing โ diagnostic checklists.