Imperal Docs
Recipes

Refresh a panel after a write

Return ActionResult.refresh_panels=[panel_id] from a chat.function so the host re-fetches your panel after the action commits

Two paths, three slots — design panels to be idempotent

refresh_panels has two distinct code paths depending on how the action was triggered:

Path A — Panel-initiated (button click → /call endpoint): The frontend reads refresh_panels from the HTTP response body and calls the panel host's callPanel(panel_id, {}, slot) for each listed panel_id — targeted, per-panel re-fetch. Since SDK v4.1.8, the host's slotFor(panel_id) resolves the listed id to its declared slot (left / right / center), so a write handler that updates a center workshop can return refresh_panels=["workshop"] and the host re-renders the center overlay — no manual ui.Call chain required.

Path B — Chat-initiated (Webbee chat → web-kernel workflow → SSE push): The web-kernel publishes a panel_refresh SSE event with data.panels: [...]. However, the frontend SSE consumer fires refreshAll() unconditionally — it does not read data.panels. Every discovered panel is re-fetched (left + right + center are all in discoveredRef), regardless of which panel_ids you listed.

Cross-extension refresh is not supported on either path. Values in refresh_panels are resolved against the current extension's panel namespace only. refresh_panels=["mail.inbox"] would attempt to call __panel__mail.inbox on your extension's call endpoint — not the mail extension.

Design your panels to be cheap and idempotent on re-fetch: a no-op refresh (no data changed) should render identically to the previous render.

ActionResult.refresh_panels takes a list of bare panel_ids — the panel_id you passed to @ext.panel(), without the __panel__ prefix. For example, @ext.panel("sidebar", ...)refresh_panels=["sidebar"]. The host prepends __panel__ internally when routing the re-fetch call.


actions.py — chat.function that writes and triggers a panel refresh
from __future__ import annotations

from pydantic import BaseModel, Field

from imperal_sdk import Extension, ActionResult, ui

ext = Extension(
    "my-ext",
    display_name="My Extension",
    description="An extension demonstrating panel refresh after a write action.",
    actions_explicit=True,
)

# ── Shared stub store — replace with your real persistence layer ───────────
_STORE: dict[str, dict] = {
    "note-1": {"id": "note-1", "title": "First note",  "body": "Original body."},
    "note-2": {"id": "note-2", "title": "Second note", "body": "Original body."},
}


# ── Pydantic params model (must be module-scope — federal invariant) ───────

class SaveNoteParams(BaseModel):
    note_id: str = Field(
        description="UUID of the note to save. Obtain from list_notes — never invent.",
    )
    body: str = Field(
        description="The full updated body of the note.",
    )


# ── chat.function — save and refresh ──────────────────────────────────────

@ext.chat.function(  # type: ignore[attr-defined]
    "save_note",
    description="Save the full body of an existing note.",
    action_type="write",
    chain_callable=True,
    effects=["note.update"],
)
async def save_note(ctx: object, params: SaveNoteParams) -> ActionResult:
    # Replace with your real persistence call:
    # await ctx.http.patch(f"/notes/{params.note_id}", json={"body": params.body})
    if params.note_id not in _STORE:
        return ActionResult(
            status="error",
            summary=f"Note {params.note_id!r} not found.",
        )

    _STORE[params.note_id]["body"] = params.body

    # refresh_panels lists bare panel_ids (no "__panel__" prefix).
    # "sidebar" → the host calls __panel__sidebar on Path A (direct button call).
    # On Path B (chat-initiated), the host fires refreshAll() regardless of this
    # list — see the callout above.
    return ActionResult(
        status="ok",
        summary="Note saved.",
        refresh_panels=["sidebar"],
    )


# ── Left sidebar — shown here for a complete runnable example ─────────────

@ext.panel(
    "sidebar",
    slot="left",
    title="Notes",
    icon="🗒️",
    default_width=280,
    min_width=200,
    max_width=500,
)
async def sidebar(ctx: object, **kwargs: object) -> object:
    notes = list(_STORE.values())
    return ui.Stack(
        children=[
            ui.List(
                items=[
                    ui.ListItem(
                        id=note["id"],
                        title=note["title"],
                        on_click=ui.Call("__panel__editor", note_id=note["id"]),
                    )
                    for note in notes
                ]
            )
        ],
        gap=2,
    )


# ── Center editor — panel that triggers save_note ─────────────────────────

@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:
        return ui.Empty(message="Select a note to edit", icon="📄")

    note = _STORE.get(note_id)
    if note is None:
        return ui.Error(message="Note not found")

    return ui.Stack(
        children=[
            ui.Text(note["title"], variant="h2"),
            ui.RichEditor(
                content=note["body"],
                # The on_change calls save_note directly. After save_note returns
                # ActionResult(refresh_panels=["sidebar"]), the host re-fetches
                # the sidebar to reflect any list-level changes (e.g., updated title).
                on_change=ui.Call("save_note", note_id=note_id),
            ),
        ],
        gap=4,
    )

Walk-through

status="ok", not ok=True. ActionResult uses status: str with the value "ok" — not a boolean ok=True. This was verified in Task 1.4 (see troubleshoot case 9 for the full API correction list). The correct constructors are ActionResult(status="ok", ...) or the factory ActionResult.success(...).

You CAN list a center panel_id since v4.1.8. Earlier SDK lines treated center as exempt from refresh_panels because the host's refreshAll() only iterated left+right. SDK v4.1.8 added center to the discovery batch + slotFor(panel_id) routing in the panel hook, so refresh_panels=["editor"] (or any declared center panel_id) now correctly re-renders the center overlay. The example here lists only ["sidebar"] because the editor's body is what the user is actively editing — re-fetching would clobber their cursor. Choose your list based on what changed, not what's possible.

Why bare panel_id, not "__panel__sidebar"? the panel call in the panel host receives p2 from refresh_panels and constructs __panel__${p2} internally. Passing "__panel__sidebar" would double-prefix to "__panel____panel__sidebar" — a 404. Always use the bare panel_id string as declared in @ext.panel("sidebar", ...).

None vs [] vs ["sidebar"] on Path A.

  • refresh_panels=None (default): Path A falls through to refreshAll() — ALL panels re-fetch.
  • refresh_panels=[] (empty list): same fallback — refreshAll() fires despite the SDK docstring claiming [] means "no refresh". This is a known contract gap.
  • refresh_panels=["sidebar"]: targeted — only __panel__sidebar is re-fetched on Path A.

For Path B (chat-initiated), all three trigger refreshAll() or nothing — the data.panels list is not consumed by the SSE handler.


On this page