Build a panel layout
Build panel layouts for the Imperal Panel: four React surface patterns, from master-detail to hub-only, covering the vast majority of extension UI shapes.
This guide walks through the four panel layouts that cover ~95% of extension UI shapes seen in production. Each layout has a When to use it section to help you pick the right one, then a complete wire-up walkthrough with a working code example.
Before starting, make sure you have read Panels — the mental model. The lifecycle section there (batch discovery → auto_action → slot replace) is assumed knowledge throughout this guide.
The layouts:
| Layout | Slots used | Reference extension |
|---|---|---|
| 1 — Master-detail | left + center | notes, sql-db, tasks |
| 2 — Center-overlay-editor | left + center (overlay) | notes (editor open path) |
| 3 — Multi-tab right panel | left + right (kwarg-branched) | tasks (details), mail |
| 4 — Hub-only-center | center only | calculator-style tools |
Layout 1 — Master-detail
When to use it
Master-detail is the right layout when your extension has a navigable list of items (notes, queries, tasks, tickets) and a content area that shows the selected item. The user browses the list in the left sidebar and the detail view fills the center.
Production examples: the Notes extension (folder/note list → rich editor), the SQL extension (saved queries list → query editor + results), and the Tasks extension (project/task list → task board).
The two decorators
from imperal_sdk import Extension, ui
ext = Extension(
"my-ext",
display_name="My Extension",
description="A master-detail extension for managing items.",
actions_explicit=True,
)
@ext.panel(
"sidebar",
slot="left",
title="Items",
icon="📜",
default_width=280,
min_width=200,
max_width=500,
)
async def sidebar(ctx: object, **kwargs: object) -> object: ...
@ext.panel(
"editor",
slot="center",
title="Editor",
icon="✏️",
)
async def editor(ctx: object, **kwargs: object) -> object: ...Two panels, two separate Python functions. The sidebar occupies the left slot; editor occupies center. The center panel is not called at session init — only left and right panels are called eagerly when the page loads. The editor is only reached via an explicit ui.Call action or through auto_action.
Wire-up
Step 1 — Editor returns ui.Empty() at batch discovery.
The editor is not called at session init, but it will be called whenever ui.Call("__panel__editor", ...) is dispatched (including from auto_action). At that point, if no item_id kwarg is supplied, return ui.Empty() — not None — so the slot stays non-null and renders a visible placeholder:
from imperal_sdk import Extension, ui
ext = Extension(
"my-ext",
display_name="My Extension",
description="A master-detail extension for managing items.",
actions_explicit=True,
)
@ext.panel("editor", slot="center", center_overlay=True, title="Editor", icon="✏️")
async def editor(ctx: object, item_id: str = "", **kwargs: object) -> object:
if not item_id:
# Canonical empty-state. Do NOT return None here:
# None leaves the center slot empty and blocks auto_action from firing.
return ui.Empty(message="Select an item", icon="📄")
item = {"id": item_id, "title": "Item", "body": "Content here"} # replace with your fetch
return ui.Stack(children=[
ui.Header(item["title"]),
ui.Text(item["body"]),
])Step 2 — Sidebar drives the editor via on_click and seeds auto_action for first load.
from imperal_sdk import Extension, ui
ext = Extension(
"my-ext",
display_name="My Extension",
description="A master-detail extension for managing items.",
actions_explicit=True,
)
@ext.panel(
"sidebar",
slot="left",
title="Items",
icon="📜",
default_width=280,
min_width=200,
max_width=500,
)
async def sidebar(ctx: object, folder_id: str = "", active_item_id: str = "",
**kwargs: object) -> object:
# Replace with your real data fetch.
items = [
{"id": "item-1", "title": "First item"},
{"id": "item-2", "title": "Second item"},
]
list_items = [
ui.ListItem(
id=item["id"],
title=item["title"],
selected=(active_item_id == item["id"]),
on_click=ui.Call("__panel__editor", item_id=item["id"]),
)
for item in items
]
root = ui.Stack(children=[ui.List(items=list_items)], gap=2)
# Seed auto_action only when no item is already active.
# This fires once after the panel loads (see Pitfall 2 below).
if not active_item_id and items:
root.props["auto_action"] = ui.Call(
"__panel__editor", item_id=items[0]["id"]
)
return root
@ext.panel("editor", slot="center", title="Editor", icon="✏️")
async def editor(ctx: object, item_id: str = "", **kwargs: object) -> object:
if not item_id:
return ui.Empty(message="Select an item", icon="📄")
item = {"id": item_id, "title": "Item", "body": "Content here"} # replace with your fetch
return ui.Stack(children=[
ui.Header(item["title"]),
ui.Text(item["body"]),
])What happens at runtime:
- Session init: host calls
__panel__sidebarwithparams: {}. Sidebar returns the list withauto_actionset to the first item. auto_actionfires: the platform dispatchesui.Call("__panel__editor", item_id="item-1")and the editor renders in the center slot. (This Layout 1 example does not setcenter_overlay=Trueon the editor, so it mounts inline rather than as an overlay — see Layout 2 for the overlay path.)- User clicks "Second item":
on_click = ui.Call("__panel__editor", item_id="item-2"). The editor content is replaced, not stacked.
Params accumulate across calls. The platform remembers the parameters each panel was last called with, merging new params over old ones per panel. If your sidebar was called with folder_id="folder-1" and then the user clicks a note, the subsequent editor call does not disturb the sidebar's remembered folder_id. When a refresh_panels=["sidebar"] triggers a re-fetch, the sidebar is re-called with its last-used folder_id preserved — you do not need to thread folder context through every action result.
Common pitfalls
Pitfall 1 — returning None instead of ui.Empty() from the editor.
If editor returns None when item_id is empty, the center slot stays empty. auto_action then fires, calls __panel__editor with no item_id, and the slot stays empty again. The user sees a blank center region with no visual feedback.
Always return ui.Empty(message="...", icon="...") as the empty-state from a center panel handler.
Pitfall 2 — not guarding auto_action on re-fetches.
If sidebar always sets auto_action regardless of active_item_id, every event-triggered sidebar re-fetch fires the auto-open again — overwriting whatever the user has open in the center. Guard with if not active_item_id and items:.
Pitfall 3 — using the __panel__ prefix in refresh_panels.
In ActionResult, refresh_panels takes bare panel_id strings: ["sidebar"], not ["__panel__sidebar"]. Do not add the __panel__ prefix yourself — the platform handles that, and a prefixed value will not match any panel.
Where to extend
- Multi-folder navigation: add a folder list above the notes list in the sidebar. Clicking a folder calls
ui.Call("__panel__sidebar", folder_id=f["id"])— same panel_id, new params. The platform re-fetches the sidebar with the new folder context. - Breadcrumbs in the editor: pass
folder_idalongsideitem_idwhen the user clicks an item so the editor can render a breadcrumb header. - Auto-open on first load: for the isolated
auto_actionpattern see recipes/panel-auto-action-on-load. - Write actions: when a save button calls a
@chat.functionthat returnsActionResult(refresh_panels=["sidebar"]), the sidebar is re-fetched with its remembered parameters. See recipes/panel-refresh-after-write.
Layout 2 — Center-overlay-editor
Opting into the center overlay is declarative.
To make a center panel render as an overlay (an editor that expands over the main chat area instead of mounting in a right-column slot), declare it with center_overlay=True:
@ext.panel("editor", slot="center", center_overlay=True, title="Editor", icon="✏️")That single field is the opt-in. You are not restricted to any fixed list of panel names, you may name your panel and its kwargs whatever you like, and no frontend change is required — the platform reads the flag and renders the panel as a center overlay. (center_overlay=True is available since SDK v4.1.8.)
When to use it
Center-overlay is the right layout when you want an immersive editing experience that expands over the main content area — for example, editing a note in a rich-text editor with the left sidebar still visible. The overlay opens on item selection and closes when the user navigates back to the list.
This layout is a specialization of Layout 1. The difference is that the center panel is declared with center_overlay=True, so opening it expands a full-width center overlay instead of mounting the editor in a right-column slot.
You choose the panel name and the kwarg that carries item identity. The example below uses panel_id="editor" with a note_id kwarg (matching the production Notes extension), but any names work as long as your on_click and handler agree.
The decorator
from imperal_sdk import Extension, ui
ext = Extension(
"my-ext",
display_name="My Extension",
description="A notes extension with center-overlay editor.",
actions_explicit=True,
)
# center_overlay=True is the opt-in for the center-overlay surface.
# Pair it with slot="center". You pick the panel name ("editor" here) and
# the kwarg that carries item identity ("note_id" here) — any names work,
# as long as your on_click and handler use the same ones.
@ext.panel("editor", slot="center", center_overlay=True, title="Editor", icon="✏️")
async def notes_editor(ctx: object, note_id: str = "", **kwargs: object) -> object: ...Wire-up
The sidebar is identical to Layout 1, with one difference: the on_click action passes the same kwarg the editor handler reads (note_id=item["id"] here), so the selected item flows into the overlay editor.
from imperal_sdk import Extension, ui
ext = Extension(
"my-ext",
display_name="My Extension",
description="A notes extension with center-overlay editor.",
actions_explicit=True,
)
# ── Left sidebar ──────────────────────────────────────────────────────────
@ext.panel(
"sidebar",
slot="left",
title="Notes",
icon="🗒️",
default_width=280,
min_width=200,
max_width=500,
refresh="on_event:notes.created,notes.updated,notes.deleted",
)
async def sidebar(ctx: object, active_note_id: str = "", **kwargs: object) -> object:
# Replace with your real data fetch.
notes = [
{"id": "note-1", "title": "Meeting notes"},
{"id": "note-2", "title": "Ideas"},
]
items = [
ui.ListItem(
id=n["id"],
title=n["title"],
selected=(active_note_id == n["id"]),
# Pass the same kwarg the editor handler reads (note_id here).
on_click=ui.Call("__panel__editor", note_id=n["id"]),
)
for n in notes
]
root = ui.Stack(children=[ui.List(items=items)], gap=2)
# auto_action: open the most-recent note on first load.
# Guard: only when no note is already active, and not in trash view.
if not active_note_id and notes:
root.props["auto_action"] = ui.Call(
"__panel__editor", note_id=notes[0]["id"]
)
return root
# ── Center overlay editor ─────────────────────────────────────────────────
@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 empty-state — do NOT return None.
return ui.Empty(message="Select a note to edit", icon="📄")
if note_id == "new":
# Create a new note stub; redirect to real ID after creation.
return ui.Stack(children=[
ui.Input(label="Title", placeholder="e.g. Meeting notes",
param_name="title",
on_submit=ui.Call("create_note", title="{{value}}")),
])
note = {"id": note_id, "title": "Meeting notes", "content": "<p>Hello</p>"} # fetch yours
return ui.Stack(children=[
ui.Stack(children=[
ui.Header(note["title"]),
ui.Button(
"Back to list", icon="⬅️", variant="ghost", size="sm",
# Navigating back to the sidebar closes the overlay.
on_click=ui.Call("__panel__sidebar", view=""),
),
], direction="h"),
ui.RichEditor(
content=note["content"],
on_change=ui.Call("save_note", note_id=note_id),
),
])What happens at runtime:
- Batch discovery:
__panel__sidebarcalled with{}. Sidebar returns the note list withauto_action = ui.Call("__panel__editor", note_id="note-1"). auto_actionfires. Because theeditorpanel declaredcenter_overlay=True, the editor opens as an overlay over the center chat area.- User clicks "Back to list":
ui.Call("__panel__sidebar", view=""). Navigating back to the sidebar closes the overlay, then the sidebar is refreshed. - User clicks another note:
ui.Call("__panel__editor", note_id="note-2"). The overlay re-renders with the new note.
Pitfalls
Pitfall 1 — forgetting center_overlay=True on the center panel.
The overlay surface is opt-in. A slot="center" panel without center_overlay=True mounts inline (Layout 1) rather than as an overlay. Declare center_overlay=True to get the overlay behavior.
Pitfall 2 — mismatched kwarg names between on_click and the handler.
The kwarg you pass in ui.Call("__panel__editor", note_id="abc") must match the parameter your editor handler reads (note_id here). If you send doc_id but the handler reads note_id, the handler receives an empty note_id and falls into its empty-state branch. Pick a kwarg name and use it consistently on both sides.
Pitfall 3 — always setting auto_action regardless of active note.
If sidebar sets auto_action unconditionally, every event-triggered sidebar re-fetch re-opens the first note in the overlay, overwriting whatever the user has open. Guard: if not active_note_id and notes:.
Pitfall 4 — expecting return None to suppress the overlay.
If editor returns None when note_id is empty, the first auto_action dispatch (before any note is selected) leaves the overlay empty and it never opens. Return ui.Empty() to keep the slot usable.
Where to extend
- Back button: always add a "Back to list" button in the overlay editor that navigates back to the sidebar (
ui.Call("__panel__sidebar", view="")); navigating away from the overlay panel closes it — there is no explicitui.Close()action in the SDK. - New-item creation:
ui.Call("__panel__editor", note_id="new")is the common pattern. Your editor handles the"new"sentinel value and creates the item inline. - Trash / archive views: these are sub-views of the sidebar, not separate panels. Pass a
view="trash"kwarg to the sidebar and branch inside the sidebar handler.
Layout 3 — Multi-tab right panel
How right-panel "tabs" actually work — an important clarification.
The platform does not render a tab strip for the right panel for you. The right slot holds a single UINode at a time. Multiple @ext.panel declarations with slot="right" do not produce a tab bar; each right-panel call fully replaces the previous right-panel content (same replace-not-stack behavior as the left slot).
Tabs in a right panel are achieved by one of two approaches:
- Single
@ext.panel(slot="right")handler that branches internally on a kwarg (e.g.,active_tab="documents"). The tab UI is rendered inside your UINode tree. - Multiple
@ext.paneldeclarations where only one is discovered at session init, and the others are reached viaui.Call(...)actions that replace the slot.
Do not attempt to declare tabs in the manifest — there is no manifest tab field; tab navigation lives entirely in your UINode tree.
When to use it
Multi-tab right panel is the right layout when your extension has a main content view in the left sidebar or center and a secondary panel on the right that shows different aspects of the selected item — for example, a task board on the left with a detail panel on the right that switches between Comments, Attachments, and History tabs.
Production examples: tasks extension (board view + task detail panel), mail extension (inbox list + email viewer / accounts / compose panels).
The pattern — single handler with kwarg branching
The most maintainable approach is a single @ext.panel(slot="right") handler that reads an active_tab (or equivalent) kwarg and branches:
from imperal_sdk import Extension, ui
ext = Extension(
"my-ext",
display_name="My Extension",
description="An extension with a tabbed right panel.",
actions_explicit=True,
)
@ext.panel(
"sidebar",
slot="left",
title="Items",
icon="📜",
default_width=280,
min_width=200,
max_width=500,
)
async def sidebar(ctx: object, **kwargs: object) -> object:
items = [{"id": "item-1", "title": "First item"}]
list_items = [
ui.ListItem(
id=item["id"],
title=item["title"],
# Open details panel with default tab on click.
on_click=ui.Call(
"__panel__details",
item_id=item["id"],
active_tab="overview",
),
)
for item in items
]
return ui.Stack(children=[ui.List(items=list_items)], gap=2)
@ext.panel(
"details",
slot="right",
title="Details",
icon="🔳",
default_width=320,
min_width=240,
max_width=480,
)
async def details(ctx: object, item_id: str = "", active_tab: str = "overview",
**kwargs: object) -> object:
if not item_id:
return ui.Empty(message="Select an item to see details", icon="ℹ️")
item = {"id": item_id, "title": "First item"} # replace with your fetch
# Tab bar rendered inside the UINode — not by the host shell.
tab_buttons = ui.Stack(
children=[
ui.Button(
"Overview",
variant="secondary" if active_tab == "overview" else "ghost",
size="sm",
on_click=ui.Call("__panel__details",
item_id=item_id, active_tab="overview"),
),
ui.Button(
"Activity",
variant="secondary" if active_tab == "activity" else "ghost",
size="sm",
on_click=ui.Call("__panel__details",
item_id=item_id, active_tab="activity"),
),
ui.Button(
"Notes",
variant="secondary" if active_tab == "notes" else "ghost",
size="sm",
on_click=ui.Call("__panel__details",
item_id=item_id, active_tab="notes"),
),
],
direction="h",
gap=1,
sticky=True,
)
if active_tab == "overview":
tab_content: object = ui.Stack(children=[
ui.Header(item["title"], level=3),
ui.Text("Overview content here"),
])
elif active_tab == "activity":
tab_content = ui.Text("Activity feed here")
else:
tab_content = ui.Text("Notes content here")
return ui.Stack(children=[tab_buttons, tab_content], gap=3)What happens at runtime:
- Batch discovery:
__panel__sidebarcalled with{}.__panel__detailsis also called with{}if it is configured asconfig.panels.right.panel_id. Withitem_id="", details returnsui.Empty(...). - User clicks "First item":
ui.Call("__panel__details", item_id="item-1", active_tab="overview"). The right slot is replaced with the details panel showing the Overview tab. - User clicks "Activity" tab:
ui.Call("__panel__details", item_id="item-1", active_tab="activity"). Same panel_id, new params — the platform updates the details panel's remembered params with the newactive_tab, and the right slot is replaced with the Activity view.
Wire-up alternative — multiple @ext.panel declarations (slot replace)
If your tabs have very different data requirements, you can declare them as separate panel handlers and use ui.Call(...) to switch between them. Only one can be the config.panels.right.panel_id for batch discovery; the others are reached via explicit action dispatch.
from imperal_sdk import Extension, ui
ext = Extension(
"my-ext",
display_name="My Extension",
description="An extension with separate right-panel handlers per tab.",
actions_explicit=True,
)
# This one is configured as config.panels.right.panel_id — called at batch discovery.
@ext.panel("inbox", slot="right", title="Inbox", icon="📥",
default_width=320, min_width=240, max_width=480)
async def inbox(ctx: object, **kwargs: object) -> object:
return ui.Stack(children=[
ui.Stack(children=[
ui.Button("Inbox", variant="secondary", size="sm",
on_click=ui.Call("__panel__inbox")),
ui.Button("Accounts", variant="ghost", size="sm",
on_click=ui.Call("__panel__accounts")),
], direction="h", gap=1, sticky=True),
ui.Text("Inbox messages here"),
])
# Not discovered at init — only reached via ui.Call from inbox tab bar.
@ext.panel("accounts", slot="right", title="Accounts", icon="👤",
default_width=320, min_width=240, max_width=480)
async def accounts(ctx: object, **kwargs: object) -> object:
return ui.Stack(children=[
ui.Stack(children=[
ui.Button("Inbox", variant="ghost", size="sm",
on_click=ui.Call("__panel__inbox")),
ui.Button("Accounts", variant="secondary", size="sm",
on_click=ui.Call("__panel__accounts")),
], direction="h", gap=1, sticky=True),
ui.Text("Accounts list here"),
])Note: switching between inbox and accounts fully replaces the right-slot UINode each time — there is no tab strip from the host and no partial-update mechanism.
Pitfalls
Pitfall 1 — declaring two slot="right" panels and expecting both to appear simultaneously.
The right slot holds one panel at a time. The second right-panel call unconditionally replaces the first (replace, not stack). Both panels cannot be visible at the same time.
Pitfall 2 — trying to configure tabs in the manifest.
There is no manifest field for right-panel tabs. Tab navigation must be wired entirely within your UINode tree.
Pitfall 3 — not passing item_id when switching tabs.
If your "Activity" tab button calls ui.Call("__panel__details", active_tab="activity") without item_id, the handler receives item_id="" and returns the empty state. Always include all the context kwargs your handler needs to reconstruct the full view.
Where to extend
- Persisting the active tab across refreshes: the platform remembers each panel's last-used params, so a
refresh_panels=["details"]re-fetch reuses the last-knownactive_tab. See concepts/panels — slot ownership for how panels keep their parameters across refreshes. - Deep-link to a specific tab from chat: a
@chat.functioncan returnActionResult(refresh_panels=["details"])and the re-fetch will use the last accumulatedactive_tab. To force a specific tab from chat, use a custom@chat.functionthat instructs the user to click.
Layout 4 — Hub-only-center
When to use it
Hub-only-center is the right layout for extensions that are a single self-contained tool — a calculator, a whiteboard, a code playground, a standalone dashboard. There is no navigable list, no editor overlay — just one persistent canvas in the center slot.
This is the simplest layout to implement and the only one where you declare a single @ext.panel with no sidebar counterpart.
The decorator
from imperal_sdk import Extension, ui
ext = Extension(
"my-ext",
display_name="My Extension",
description="A standalone hub tool with a single center panel.",
actions_explicit=True,
)
@ext.panel(
"hub",
slot="center",
center_overlay=True,
title="My Tool",
icon="🪟",
)
async def hub(ctx: object, **kwargs: object) -> object: ...No slot="left" companion. With no left or right panel declared, nothing is discovered at session init, so there is no left panel. Because there is no left panel, auto_action never fires — you do not need to worry about the auto_action contract in this layout.
The hub panel is only reachable via explicit ui.Call(...) dispatch from within the panel's own UI (buttons, inputs, on_change handlers).
Wire-up
from imperal_sdk import Extension, ui
ext = Extension(
"my-ext",
display_name="My Extension",
description="A standalone hub tool with a single center panel.",
actions_explicit=True,
)
@ext.panel(
"hub",
slot="center",
center_overlay=True,
title="Calculator",
icon="🧮",
)
async def hub(ctx: object, expression: str = "", result: str = "",
**kwargs: object) -> object:
# State is passed back as kwargs on each ui.Call dispatch.
# For richer state, use ctx.store for cross-session persistence.
return ui.Stack(
children=[
ui.Header("Calculator"),
ui.Input(
label="Expression",
placeholder="e.g. 2 + 2",
param_name="expression",
value=expression,
on_submit=ui.Call("__panel__hub",
expression="{{value}}",
result=""),
),
ui.Stack(
children=[
ui.Button(
"Evaluate",
variant="primary",
on_click=ui.Call("evaluate_expression",
expression=expression),
),
ui.Button(
"Clear",
variant="ghost",
on_click=ui.Call("__panel__hub",
expression="",
result=""),
),
],
direction="h",
gap=2,
),
ui.Text(f"Result: {result}" if result else "", variant="code"),
],
gap=4,
)State management options:
| Approach | When to use | How |
|---|---|---|
| Kwarg pass-through | Short-lived ephemeral state (form values, toggle states) | Pass state as kwargs in ui.Call — the platform remembers a panel's last-used params |
ctx.store | State that should survive page refresh or be shared across sessions | Use SDK ctx.store.get / ctx.store.set inside the handler |
ActionResult.refresh_panels | After a write action, re-render the hub with new data | Return refresh_panels=["hub"] — host re-fetches with accumulated params |
Pitfalls
Pitfall 1 — using slot="bottom" for a footer strip.
slot="bottom" passes SDK validation but is not rendered today — it has no visible surface. If you want a footer inside your hub panel, render it as a child UINode inside your slot="center" handler's return tree. Use ui.Stack(children=[main_content, footer], gap=0) with footer as a pinned bottom child.
Pitfall 2 — registering a slot="left" panel alongside the hub without intending to.
If you accidentally register a slot="left" panel (e.g., copied from a master-detail extension), it is discovered at session init and adds a left panel. If that left panel has auto_action set, it fires and may load an unexpected panel into the center. In the hub-only layout, keep the declaration list to a single slot="center" panel.
Pitfall 3 — using slot="chat-sidebar" for a persistent sidebar.
Like slot="bottom", slot="chat-sidebar" passes SDK validation but is not rendered today. Use slot="left" for a left sidebar or combine into a single slot="center" panel with an internal sidebar column.
Where to extend
- Persist user settings between visits: use
ctx.storein your hub handler to read/write user preferences. The store survives page reloads and is scoped per user+extension. - Event-driven refresh: if your hub displays live data (e.g., a real-time dashboard), add
refresh="on_event:your_scope.event_name"to the@ext.paneldecorator. On matching events the platform re-calls your hub handler with its remembered params. - Wiring to chat: a
@chat.functionthat updates data can returnrefresh_panels=["hub"]to trigger a hub re-render after a write. See concepts/panels — Four ways a panel updates for the full set of panel-update paths and their caveats.
Where next
Recipe — master-detail
Copy-paste minimal master-detail implementation with annotations.
Recipe — center-overlay
The smallest possible center-overlay-editor with auto_action and back button.
Recipe — auto-action on load
Isolated auto_action pattern — auto-open the most-recent item on panel mount.
Recipe — refresh after write
Return [ActionResult](/en/reference/glossary/).refresh_panels to trigger a panel re-fetch from a chat.function.
Troubleshoot a panel
Overlay does not open / panel refreshes instead of replaces / live refresh not firing — diagnostic checklists.
@ext.panel reference
Every kwarg, every accepted value, every default — anchored against extension.py source.
Panels — mental model
Slot ownership, lifecycle, the four dispatch sources — required reading for all layouts.
Audit & security
Audit and security in Imperal Cloud: the action ledger, retention classes, tenant isolation, and exactly what every Webbee extension action records for free.
Troubleshoot a panel
Troubleshoot Imperal Panel rendering and data-loading issues with ten diagnostic cases covering overlays, refresh, slots, tabs, and missing decorators.