Imperal Docs
Guides

Build a panel layout

Four canonical layouts — master-detail, center-overlay-editor, multi-tab-right, hub-only-center — end to end

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:

LayoutSlots usedReference extension
1 — Master-detailleft + centernotes, sql-db, tasks
2 — Center-overlay-editorleft + center (overlay)notes (editor open path)
3 — Multi-tab right panelleft + right (kwarg-branched)tasks (details), mail
4 — Hub-only-centercenter onlycalculator-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

panels.py — decorator declarations
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",
    center_overlay=True,
    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. Neither is called at batch discovery for the center slot — only left and right panels are eagerly called at session init (Q2 — verified-stable). 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:

panels.py — editor with empty-state guard
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 (Q3 — verified-stable):
        # None leaves the slot null 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.Text(item["title"], variant="h2"),
        ui.Text(item["body"]),
    ])

Step 2 — Sidebar drives the editor via on_click and seeds auto_action for first load.

panels.py — full master-detail wire-up
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 per session on first load (Q4 — verified-unstable, see 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", center_overlay=True, 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.Text(item["title"], variant="h2"),
        ui.Text(item["body"]),
    ])

What happens at runtime:

  1. Session init: host calls __panel__sidebar with params: {}. Sidebar returns the list with auto_action set to the first item.
  2. auto_action fires: host dispatches ui.Call("__panel__editor", item_id="item-1"). Because panel_id == "editor" but no note_id kwarg is present (our kwarg is item_id, not note_id), the center-overlay routing rule returns false — see Layout 2 for the overlay path. The editor renders in a right-column slot, not a center overlay. For a pure left+center layout without overlay, this is the correct behavior.
  3. User clicks "Second item": on_click = ui.Call("__panel__editor", item_id="item-2"). The editor slot is replaced (Q1 — replace semantics, not stack).

Params accumulate across calls. The host's the accumulated panel params merges new params over old ones for each panel_id. 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 accumulated panel params["sidebar"]. 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 host's batch-discovery guard (results[idx]?.ui check) leaves the slot null. Because leftPanel is non-null but the center is null, auto_action fires, calls __panel__editor, and… the slot stays null again because item_id is still empty. The user sees a blank center region with no visual feedback (Q3 — verified-stable).

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 SSE-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: (see the Notes extension's for the production pattern).

Pitfall 3 — using __panel__ prefix in refresh_panels.

In ActionResult, refresh_panels takes bare panel_id strings: ["sidebar"], not ["__panel__sidebar"]. The host prepends the prefix internally. Passing "__panel__sidebar" results in a double-prefix call (__panel____panel__sidebar) that returns nothing (Q13 — verified-stable).

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 host merges them into the accumulated panel params and re-fetches with the new folder context. See notes/panels.py for the production implementation.
  • Breadcrumbs in the editor: pass folder_id alongside item_id when the user clicks an item so the editor can render a breadcrumb header.
  • Auto-open on first load: for the isolated auto_action pattern see recipes/panel-auto-action-on-load.
  • Write actions: when a save button calls a @chat.function that returns ActionResult(refresh_panels=["sidebar"]), the sidebar is re-fetched via Path A (targeted, uses accumulated params). See recipes/panel-refresh-after-write.

Layout 2 — Center-overlay-editor

Verified-unstable contract — Q4 + Q10.

The center-overlay behavior (an editor that slides over the main chat area) is driven by a hardcoded the center-overlay routing rule allowlist in of the panel host. Only three panel_id values reach the center overlay:

  • "editor" — only when the note_id kwarg is truthy
  • "compose" — always
  • "email_viewer" — only when the message_id kwarg is truthy

A new extension cannot opt a panel into center-overlay routing via the SDK. If your panel_id is not one of the three above, the center-overlay routing rule returns false and the panel is routed to the right slot instead. Adding a new panel_id to this behavior requires a frontend code change to the panel host. There is no manifest or decorator field that opts in.

This guide documents the verified behavior for extensions that use the allowlisted panel_id values.

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 editor call goes through the center-overlay routing rule, opening the full-width center overlay instead of mounting the editor in a right-column slot.

You must use panel_id="editor" with a kwarg named note_id to reach the overlay path. This is the contract from the production Notes extension; the the center-overlay routing rule check is literally pid === 'editor' && !!p.note_id.

The decorator

panels_editor.py — center-overlay editor declaration
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,
)

# panel_id MUST be "editor" for the center-overlay routing rule routing.
# The kwarg that carries the item identity MUST be named "note_id".
# slot="center" is declared but the host does not use the slot= field
# for routing decisions — the center-overlay routing rule fires on panel_id + note_id presence
# (Q10 — verified-unstable).
@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 note_id=item["id"] (not item_id=) because the the center-overlay routing rule check is !!p.note_id.

panels.py + panels_editor.py — center-overlay wire-up
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"]),
            # note_id= kwarg is required for the center-overlay routing rule routing.
            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(placeholder="Note title...", 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.Text(note["title"], variant="h2"),
            ui.Button(
                "Back to list", icon="⬅️", variant="ghost", size="sm",
                # Navigating to "sidebar" triggers the overlay clear-on-navigate rule → overlay closes.
                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:

  1. Batch discovery: __panel__sidebar called with {}. Sidebar returns the note list with auto_action = ui.Call("__panel__editor", note_id="note-1").
  2. auto_action fires. Host evaluates the center-overlay routing rule("editor", {note_id: "note-1"})true. the center-overlay mount(result.ui) — the editor opens over the center chat area.
  3. User clicks "Back to list": ui.Call("__panel__sidebar", view=""). Host evaluates the overlay clear-on-navigate rule("sidebar", {})true. the center-overlay mount(null) — overlay closes. Then the sidebar is refreshed.
  4. User clicks another note: ui.Call("__panel__editor", note_id="note-2"). Overlay opens with the new note.

Pitfalls

Pitfall 1 — using a custom panel_id instead of "editor".

the center-overlay routing rule is a literal string check. panel_id="my_editor" routes to the right slot, not the overlay. You must use one of the three allowlisted values.

Pitfall 2 — passing the item ID under a kwarg name other than note_id.

the center-overlay routing rule checks !!p.note_id. If you pass ui.Call("__panel__editor", doc_id="abc"), p.note_id is undefined → the center-overlay routing rule returns false → the editor opens in the right slot, not the overlay. The kwarg name must be exactly note_id.

Pitfall 3 — always setting auto_action regardless of active note.

If sidebar sets auto_action unconditionally, every SSE-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: (production pattern from notes/panels.py).

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) sets centerOverlay(null) — the overlay never opens. Use ui.Empty() to keep the slot usable.

Where to extend

  • Back button: always add a "Back to list" button in the overlay editor that calls ui.Call("__panel__sidebar", view=""). The the overlay clear-on-navigate rule check on "sidebar" closes the overlay — there is no explicit ui.Close() action in the SDK (Q8 — verified-stable).
  • New-item creation: ui.Call("__panel__editor", note_id="new") is the production pattern (notes/panels.py). 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. See notes/panels.py.

Layout 3 — Multi-tab right panel

Verified-stable with an important clarification — Q9.

The platform shell does not render a host-managed tab strip for the right panel. There is one rightPanel React state value per extension page — a single UINode. Multiple @ext.panel declarations with slot="right" do not produce a tab bar; each the panel call call fully replaces the previous right-panel content (same replace semantics as the left slot per Q1).

Tabs in a right panel are achieved by one of two approaches:

  1. Single @ext.panel(slot="right") handler that branches internally on a kwarg (e.g., active_tab="documents"). The tab UI is rendered inside the UINode tree — not by the host shell.
  2. Multiple @ext.panel declarations where only one is the configured right.panel_id for batch discovery, and the others are reached via ui.Call(...) actions that replace the slot.

Do not attempt to use right_panel.tabs from the manifest — this field is not honored by the current host.

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:

panels.py — multi-tab right panel via kwarg branching
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.Text(item["title"], variant="h3"),
            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:

  1. Batch discovery: __panel__sidebar called with {}. __panel__details is also called with {} if it is configured as config.panels.right.panel_id. With item_id="", details returns ui.Empty(...).
  2. 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.
  3. User clicks "Activity" tab: ui.Call("__panel__details", item_id="item-1", active_tab="activity"). The same panel_id, new params — the accumulated panel params["details"] is updated with the new active_tab. 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.

panels.py — separate panel declarations per tab (slot replace pattern)
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 host has one rightPanel state value. The second call to the panel call unconditionally replaces the first (Q1 — replace semantics). Both panels cannot be visible at the same time.

Pitfall 2 — trying to configure right_panel.tabs in the manifest.

There is no right_panel.tabs field honored by the current panel host. 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: pass active_tab in your ActionResult(refresh_panels=["details"]) — the host merges it into the accumulated panel params["details"], so the re-fetch uses the last-known tab. See concepts/panels#slot-ownership--who-renders-what for how the accumulated panel params accumulation works.
  • Deep-link to a specific tab from chat: a @chat.function can return ActionResult(refresh_panels=["details"]) and the re-fetch will use the last accumulated active_tab. To force a specific tab from chat, use a custom @chat.function that 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

panels.py — hub-only-center declaration
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. The batch discovery POST at session init will contain no panel calls (there is no left.panel_id or right.panel_id in config), so leftPanel stays null. Because leftPanel is null, 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

panels.py — hub-only-center full working example
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.Text("Calculator", variant="h2"),
            ui.Input(
                placeholder="Enter expression, 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:

ApproachWhen to useHow
Kwarg pass-throughShort-lived ephemeral state (form values, toggle states)Pass state as kwargs in ui.Call — host accumulates via the accumulated panel params
ctx.storeState that should survive page refresh or be shared across sessionsUse SDK ctx.store.get / ctx.store.set inside the handler
ActionResult.refresh_panelsAfter a write action, re-render the hub with new dataReturn refresh_panels=["hub"] — host re-fetches with accumulated params

Pitfalls

Pitfall 1 — using slot="bottom" for a footer strip.

slot="bottom" is in ALLOWED_PANEL_SLOTS and passes SDK validation, but the frontend renders it as nothing — the slot has no render path in the current host (Q10 — verified-unstable). 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), the batch discovery includes it and leftPanel becomes non-null. If leftPanel 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" is in ALLOWED_PANEL_SLOTS but is not rendered by the current host (Q10 — verified-unstable). 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.store in your hub handler to read/write user preferences. The store survives page reloads and is scoped per user+extension.
  • SSE-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.panel decorator. The host fires refreshAll() on matching SSE events, re-calling your hub handler with accumulated params (Q7 — verified-stable).
  • Wiring to chat: a @chat.function that updates data can return refresh_panels=["hub"] to trigger a hub re-render after a write. This works via Path A (panel-initiated direct call) reliably; see concepts/panels — Four ways a panel updates for Path B (chat-initiated SSE) caveats.

Where next

On this page