Panels
Panels — React surfaces your extension renders inside the Imperal Panel: sidebars, editors, dashboards, and overlays the user sees and interacts with directly.
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 and raises SkeletonAccessForbidden if you call it 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:
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 Panel app calls this tool when it needs to render the panel — at session init for permanent slots, or on demand when an action targets it.
The 6 allowed slot values
The slot argument is validated at decoration time against the SDK's allowed set; any other value raises ValueError immediately. What the Imperal Panel app 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 Panel app fetches it at session-init alongside left/right. When active, chat shrinks to a narrow 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
Since SDK v4.1.8: declare center_overlay=True and the Panel app renders the panel as a center overlay automatically — no boilerplate auto_action for the common case:
@ext.panel(
"workshop",
slot="center",
title="Automation Workshop",
icon="🔀",
center_overlay=True, # ← declarative
)
async def workshop_panel(ctx, **kwargs):
return ui.Stack([...])When you deploy via panel.imperal.io/developer, the platform records the center_overlay flag against your center panel. At session init the Panel app fetches the center panel alongside left/right and, when your handler returns a non-empty UINode, expands it over the chat region (chat shrinks to a narrow right rail). Dismiss the overlay — by navigating to another panel or returning None from a refresh — and chat returns to full width.
Declarative center-overlay shipped in v4.1.8.
Adding a new center-overlay extension is zero-config: declare @ext.panel(slot="center", center_overlay=True), deploy via panel.imperal.io/developer, and the Panel app picks it up automatically — no auto_action boilerplate required.
slot="overlay" / slot="bottom" / slot="chat-sidebar" are accepted by the SDK validator at decoration time but the Panel app has no render path for them yet. If you declare @ext.panel("my_viewer", slot="overlay"), the panel is registered fine but will never be rendered today. These slot values remain reserved for future work.
Host-recognized **kwargs
Only three **kwargs travel end-to-end from the SDK decorator through to the Panel app:
| 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 apply only to "left" and "right" panels; they are ignored for slot="center". Any other **kwargs you pass are accepted by the decorator but are not forwarded to the Panel app.
Slot ownership — who renders what
Slots at session init (batch discovery)
When a user navigates to your extension page, the Panel app fetches every configured slot in a single batch — one call per configured slot. The slot set comes from your extension's configured left and right panel_ids. This batch discovery fires at most once per browser session per extension + slot combination.
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 unmounts cleanly and the new UINode renders in its place.
The one exception is pagination: if a response includes a cursor field, the panel re-fetch runs in append mode and merges the new list items into the existing UINode. 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 the slot populated,
# 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 your handler returns None, the slot is left empty. An empty left slot also blocks auto_action from firing, so a center overlay configured to auto-open never appears.
ui.Empty() returns a proper UINode, keeps the slot populated, 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=...) | Populated (placeholder rendered) | Yes (if conditions met) | Placeholder card |
ui.Error(message=...) | Populated (error card rendered) | Yes | Error card |
None | Stays empty (or unchanged for non-discovery calls) | No | Blank region |
Slot ownership rules summary
- Replace: each panel call for a slot replaces the previous UINode unconditionally.
- Params accumulate: the Panel app 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 are not cancelled — whichever 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 The Panel app fetches every configured slot in one batch:
__panel__sidebar (params: {})
__panel__details (params: {})
Your handlers run; results populate the left/right slots.
│
▼
AUTO_ACTION CHECK If the left panel declares an auto_action and no overlay is
open, the Panel app fires it once. E.g., opens the editor for
the most-recent item.
│
▼
INITIAL RENDER User sees: left sidebar + (optional) center panel.
Right panel visible if a right slot is configured.
│
▼
USER CLICKS User clicks a list item. on_click = ui.Call("__panel__editor", note_id="abc")
The Panel app calls __panel__editor with { note_id: "abc" }
and opens the result as a center overlay.
│
▼
USER WRITES User edits content. A button fires ui.Call("save_note", ...).
save_note (@chat.function) runs and returns
ActionResult.refresh_panels = ["sidebar"].
The Panel app re-fetches sidebar with accumulated params (Path A).
│
▼
USER NAVIGATES User clicks "Back to Notes" = ui.Call("__panel__sidebar", view="")
The center overlay closes and the sidebar is refreshed.
│
▼
UNMOUNT User navigates away. Panel state resets; the next visit
triggers a fresh batch discovery.Step-by-step breakdown
Step 1 — SESSION INIT. The user opens the extension page. The Panel app reads your extension's configured left panel_id (e.g. "sidebar") and optional right panel_id.
Step 2 — BATCH DISCOVERY. The Panel app fetches every configured slot in a single batch — one entry per slot, each with params: {}. This fires at most once per browser session per extension + slot combination; navigating away and back triggers a fresh discovery.
Step 3 — INITIAL RENDER. The batch results populate the left and/or right slots. If a handler returned no UINode, that slot stays empty.
Step 4 — AUTO_ACTION CHECK. After discovery completes, the Panel app reads the left panel's auto_action. If it is a ui.Call(...) action, the app dispatches it once. This is how master-detail extensions auto-open a center panel on first load.
Step 5 — USER INTERACTION. Every on_click, on_change, or similar prop in your UINode tree produces a ui.Call(...) action. The Panel app dispatches the call, receives the new UINode, and routes it: a center panel opens as an overlay, a left/right panel replaces its slot.
Step 6 — WRITE + REFRESH. When a @chat.function returns ActionResult(refresh_panels=["sidebar"]), the Panel app re-fetches the listed panel_ids using their accumulated params. See § Four ways a panel updates for the full two-path behavior.
Step 7 — UNMOUNT. On navigation away, the Panel app discards the page's panel state. 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 Panel app 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. All incoming params are spread as **params into your __panel__* handler. There is no routing split based on whether a kwarg matches a declared parameter name in your handler. (At discovery time params: {} is sent; at action time the accumulated panel params are merged in — which is why a panel can appear to behave differently on first load versus after interaction.) Param names are never inspected against your handler signature before dispatch.
2. ActionResult.refresh_panels=[...]
After a @chat.function write action, return refresh_panels in your ActionResult to trigger a panel re-fetch:
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 Panel app adds the prefix for you.
There are two paths with different scoping behavior:
Path A — panel-initiated: When a panel button triggers a @chat.function, the Panel app reads refresh_panels from the result and re-fetches each listed panel. This is targeted: only the listed panels are re-fetched, each preserving its accumulated user context.
Path B — chat-initiated: When a @chat.function driven from chat returns refresh_panels, the Panel app refreshes all discovered panels rather than only the listed ones. And it only does so when your extension is configured for event-driven refresh — extensions on the default refresh="manual" do not get a chat-initiated refresh at all.
Path B gap. If your user asks Webbee in chat to create a note and your create_note function returns refresh_panels=["sidebar"], the sidebar refreshes automatically only if your sidebar panel's refresh= includes an on_event: matcher for that change. 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 (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:
auto_actionis read only from the left panel's root UINode props — not from the right panel, an open center overlay, or any nested node.- It fires once per extension page session. Navigating away and back resets it and fires again on the next discovery.
- It fires only when: discovery is complete, no overlay is already open, the left slot is populated, and the value is a
ui.Call(...)action. - The format is the action object produced by
ui.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 Panel app fetches the panel at session-init alongside left/right in the same batch — 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)When you declare center_overlay=True, an auto_action targeting that center panel_id opens it as an overlay. Otherwise the call is routed to the right slot.
4. Push updates from platform events
The Panel app listens to the platform's live event stream and matches incoming events against your extension's declared refresh events. When a matching event arrives, the app re-fetches your panels, preserving each panel's accumulated user context.
To enable event-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 platform emits an event with a matching scope+action, your panels refresh. The matching is three-way: qualified (scope.action), bare action, or bare scope.
Event-driven refresh re-fetches ALL of your discovered panels, not only the one that declared the event. There is no per-panel event routing — the refresh declaration is 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:
@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 records the slot metadata (slot, title, icon, refresh, width kwargs) you declared. The platform uses that metadata to configure your extension page layout. The Panel app calls the handler at batch discovery (left/right slots) or on explicit ui.Call(...) action.
This is the primary UI primitive for extension development. The notes, sql-db, and tasks reference extensions 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, widget metadata is discovered through a separate mechanism — the dashboard surface reads widget registrations; panel discovery does not.
Production usage today: none. @ext.widget is SDK-defined and callable but no production or reference extension uses it. Widget behavior and the dashboard surface that renders widgets are not covered here — 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 records a tray definition (tray_id, icon, tooltip). The platform's top navigation bar reads your tray registrations at startup to render icons. Tray handlers return a UINode containing a badge and optional dropdown panel.
Production usage today: none. @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 surface on-demand | Embedding a stat tile or summary card in the dashboard |
@ext.tray | Top navigation bar | Top-bar (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 platform events or time intervals.
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 the platform emits matching 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 | Behavior | Takes effect? |
|---|---|---|
"manual" (default) | No auto-refresh; the panel updates only on explicit ui.Call dispatch. | Treated as the default. |
"on_event:scope.action[,...]" | Your panels re-fetch on any matching platform event. | Yes — the on_event: events you declare across your left/right panels are merged and applied. |
"interval:Ns" | Would poll every N seconds. | No — interval refresh is not applied from the decorator (see callout). |
refresh="interval:Ns" has no effect when set through the decorator.
Only on_event: refresh declarations take effect when set via @ext.panel. A panel decorated with @ext.panel("sidebar", refresh="interval:30s") records the string, but interval-based refresh is not activated from the decorator today.
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 not activated from the decorator. Do not use refresh_seconds=N.
Interval polling cannot be configured through the SDK decorator today.
Multi-panel extensions and refresh scope
For extensions with both a left and right panel, the platform merges all on_event: events across your left/right panels into a single set. When any event in that merged set matches, all of your discovered panels re-fetch. 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.
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.
Events
Events — emit and react to typed events across Imperal Cloud extensions, with cross-extension subscriptions and a minimal, well-defined handler context model.
Web-kernel context (ctx)
Web-kernel context (ctx) — every field your handler receives: user, store, AI, HTTP, secrets and more, user-scoped and federally injected once auth checks pass.