Imperal Docs
SDK Reference

@ext.menu_item reference

@ext.menu_item reference — contribute an entry to the Panel's top-right user menu: navigation links vs dispatched handlers, sections and ordering.

@ext.menu_item contributes an entry to the user menu behind the avatar in the Panel's top right — the menu that holds profile, theme and sign-out.

Use it for a destination the user reaches occasionally and deliberately: a workspace, a settings screen, an admin tool. It is the opposite of a tray item: the tray is glanceable state, the menu is a deliberate destination.

New in SDK 5.10.0

Before 5.10.0 this menu was a hardcoded list of four links with no way in. It is now composed: the host renders its own entries and every declared one through the same path. Requires kernel support for the published menu surface; older kernels ignore the key.

Where it lives

@ext.menu_item is a method on the Extension instance, not on ChatExtension.

from imperal_sdk import Extension

ext = Extension(
    "my-app",
    version="1.0.0",
    display_name="My App",
    description="My App — AI-powered tool for managing your resources.",
    icon="icon.svg",
    actions_explicit=True,
)

@ext.menu_item("workspace", label="My Workspace", icon="LayoutGrid", path="/ext/my-app")
async def menu_workspace(ctx, **kwargs):
    return None

Signature

def menu_item(
    self,
    item_id: str,
    label: str = "",
    icon: str = "",
    section: str = "main",
    path: str = "",
    order: int = 100,
    danger: bool = False,
) -> Callable:
    ...

Two shapes

The presence of path decides whether your handler runs at all. This is the single most important thing on this page.

The host routes straight to path. The handler is never called. Give it a body of return None; it exists only to carry the declaration.

@ext.menu_item("workspace", label="My Workspace", icon="LayoutGrid", path="/ext/my-app")
async def menu_workspace(ctx, **kwargs):
    return None

2. Dispatched action — no path

Clicking calls __menu__{item_id}, and the result is handled like any other panel action: return a UINode to open it, or an action to navigate or refresh.

@ext.menu_item("quick_report", label="Today's report", icon="FileText")
async def menu_quick_report(ctx, **kwargs):
    rows = await ctx.store.query("events", where={"day": "today"})
    return ui.Modal(
        title="Today",
        content=ui.Stack([
            ui.Stat(label="Events", value=str(len(rows))),
            ui.Button("Open full report", on_click=ui.Navigate("/ext/my-app/report")),
        ]),
    )

A handler with a path is dead code

If you set path= and also write real logic in the handler, that logic never runs — and nothing warns you. Pick one shape per item.


Kwargs reference

item_id

Prop

Type

label

Prop

Type

icon

Prop

Type

section

Prop

Type

The schema knows four sections — main, admin, account, footer — but only the first two accept contributions:

SectionContributableWhat it is
main✅ DefaultOrdinary destinations
adminAdmin-only tools; inherits the host's admin gate
account❌ RejectedPlatform identity entries
footer❌ RejectedPlatform theme and sign-out

account and footer are refused in two places

Passing either raises ValueError from the decorator and fails manifest schema validation. Two gates on purpose: a manifest can be hand-edited, and the consequence is not cosmetic — an item appearing there shoves Sign out sideways under a cursor already moving toward it.

path

Prop

Type

order

Prop

Type

danger

Prop

Type


Handler signature

async def handler(ctx, **kwargs) -> UINode | dict | None:
    ...

Accept **kwargs — the host may pass context the SDK does not model yet.

If the return value has a .to_dict() method, the decorator serialises it to {"ui": ..., "item_id": item_id}. Anything else is passed through unchanged, so returning None (the correct body for a path= link) is fine.


Complete example

# Ordinary destination.
@ext.menu_item("workspace", label="My Workspace", icon="LayoutGrid",
               path="/ext/my-app", order=10)
async def menu_workspace(ctx, **kwargs):
    return None


# Admin-gated tool — hidden entirely for non-admins.
@ext.menu_item("audit", label="Audit log", icon="ScrollText",
               section="admin", path="/ext/my-app/audit")
async def menu_audit(ctx, **kwargs):
    return None


# Dispatched action with a confirmation, marked destructive.
@ext.menu_item("purge_cache", label="Purge cached data", icon="Trash2", danger=True)
async def menu_purge_cache(ctx, **kwargs):
    return ui.Dialog(
        title="Purge cached data?",
        content=ui.Text("Cached results will be rebuilt on next use."),
        confirm_label="Purge",
        on_confirm=ui.Call("do_purge_cache"),
        destructive=True,
    )

Anti-patterns

Do not put frequent actions here. The menu costs two clicks and is hidden by default. Something used often belongs in a panel or a tray item.

Do not reach for account or footer. They are refused, by design, in two places.

Do not mark everything danger=True. If three entries are red, none of them reads as dangerous.


Manifest output

{
  "menu": [
    {
      "item_id": "workspace",
      "label": "My Workspace",
      "icon": "LayoutGrid",
      "section": "main",
      "path": "/ext/my-app",
      "order": 10,
      "danger": false
    }
  ]
}

An extension that declares no menu items emits no menu key at all, so every manifest written before 5.10.0 rebuilds byte-identical.

A menu entry alone is enough to publish

Since 5.10.0 an extension contributing only a menu entry — no panel — publishes its UI surface. Previously the publisher returned early and the declaration was dropped silently.


Cross-references

On this page