Imperal Docs
SDK Reference

@ext.tray reference

@ext.tray reference — contribute an item to the Panel's system tray: the three zones, ordering, badges, dropdown panels, and the TrayResponse envelope.

@ext.tray puts an item in the Panel's top bar — the strip that holds the clock, the settings gear and the platform's own status indicators. An item is an icon with an optional badge, and optionally a dropdown panel that opens when the icon is clicked.

Use a tray item for something the user should see without opening anything: an unread count, a connection state, a running job, a balance. If the content only makes sense once the user goes looking for it, that is a panel, not a tray item.

Reachable since SDK 5.10.0

@ext.tray existed as a decorator for a long time, but the kernel did not know the word tray — a declared item was written into imperal.json and silently dropped. Since 5.10.0 it travels the same publish path as panels and actually renders. It also gained zone and order. If you are pinned below 5.10.0, a tray declaration is inert.

Where it lives

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

from imperal_sdk import Extension, ui

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.tray("unread", icon="Mail", tooltip="Unread messages")
async def tray_unread(ctx, **kwargs):
    count = await ctx.store.count("messages", where={"read": False})
    return ui.TrayResponse(
        badge=ui.Badge(str(count), color="red" if count else "gray"),
    )

Signature

def tray(
    self,
    tray_id: str,
    icon: str = "Circle",
    tooltip: str = "",
    zone: str = "status",
    order: int = 100,
) -> Callable:
    ...

Kwargs reference

Prop

Type


The three zones

A tray is not a row of icons, it is three groups that happen to sit next to each other. That is why zone exists and why it is the first thing to get right.

Prop

Type

Zones render left to right in that fixed order, and sorting happens kernel-side — so two hosts rendering the same tray cannot disagree about what comes first.

# Passive state — the default zone.
@ext.tray("sync_state", icon="RefreshCw", tooltip="Sync status", zone="status")
async def tray_sync_state(ctx, **kwargs):
    ...

# Something the user flips.
@ext.tray("pause", icon="Pause", tooltip="Pause monitoring", zone="actions", order=10)
async def tray_pause(ctx, **kwargs):
    ...

Do not reach for `system` by default

system is the far-right furniture next to the clock and the settings gear. An extension item there competes with controls the user reaches for by muscle memory. Unless your item is genuinely platform-level, use status or actions.

Ordering

order sorts within a zone, ascending, and defaults to 100. The platform's own items reserve 0-99, so the default lands an extension after them without having to know anyone else's numbers.

Only reach for an explicit order when you ship several items and want a stable relationship between them:

@ext.tray("queue_depth", icon="Layers", zone="status", order=100)   # first
@ext.tray("last_error", icon="AlertTriangle", zone="status", order=110)  # after it

Handler signature

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

The handler is fetched through the /call endpoint as the synthetic tool __tray__{tray_id}. Accept **kwargs — the host may pass context the SDK adds later.

If the returned object has a .to_dict() method, the decorator serialises it to {"ui": ..., "tray_id": tray_id, "icon": icon} before returning. Anything without .to_dict() is passed through unchanged.


ui.TrayResponse — badge and dropdown

TrayResponse is the envelope a tray handler returns. It carries two independent slots:

Prop

Type

@ext.tray("unread", icon="Mail", tooltip="Unread", zone="status")
async def tray_mail(ctx, **kwargs):
    count = await ctx.store.count("messages", where={"read": False})
    msgs = await ctx.store.query("messages", where={"read": False}, limit=5)

    return ui.TrayResponse(
        badge=ui.Badge(str(count), color="red" if count else "gray"),
        panel=ui.List(items=[
            ui.ListItem(id=m["id"], title=m["subject"], subtitle=m["from"])
            for m in msgs.data
        ]) if msgs.data else None,
    )

TrayResponse is an envelope, not a component

It is unpacked by the kernel, which renders badge and panel with their own real components. That is why there is deliberately no TrayResponse entry in the panel component registry — its absence there is not a gap.

Both slots are optional:

  • badge only — a passive indicator with nothing to open.
  • panel only — an icon that opens a dropdown but carries no count.
  • neither — nothing renders; return this when there is genuinely nothing to show rather than a zero badge.

Patterns

Badge that disappears when it is zero

A permanent grey 0 is visual noise. Return no badge at all instead:

@ext.tray("alerts", icon="Bell", tooltip="Alerts", zone="status")
async def tray_alerts(ctx, **kwargs):
    count = await ctx.store.count("alerts", where={"acknowledged": False})
    if not count:
        return ui.TrayResponse()          # icon stays, badge does not
    return ui.TrayResponse(
        badge=ui.Badge(str(count), color="red"),
        panel=await _alerts_panel(ctx),
    )

Connection state as colour, not text

@ext.tray("connection", icon="Plug", tooltip="Connection", zone="status")
async def tray_connection(ctx, **kwargs):
    ok = await _probe(ctx)
    return ui.TrayResponse(
        badge=ui.Badge("", color="green" if ok else "red", dot=True),
        panel=ui.Card(
            title="Connection",
            content=ui.Text("Connected" if ok else "Disconnected — check credentials"),
        ),
    )

An actionable dropdown

The dropdown is an ordinary UI tree, so it can contain buttons that call back into your extension:

@ext.tray("jobs", icon="Activity", tooltip="Running jobs", zone="actions")
async def tray_jobs(ctx, **kwargs):
    jobs = await ctx.store.query("jobs", where={"state": "running"})
    return ui.TrayResponse(
        badge=ui.Badge(str(len(jobs.data)), color="blue") if jobs.data else None,
        panel=ui.Stack([
            ui.Text("Running jobs", variant="subtitle"),
            *[ui.Row([
                ui.Text(j["name"]),
                ui.Button("Stop", variant="ghost", size="sm",
                          on_click=ui.Call("stop_job", job_id=j["id"])),
            ]) for j in jobs.data],
        ]) if jobs.data else ui.Empty("No jobs running"),
    )

Anti-patterns

Do not do expensive work in a tray handler. It is fetched often and on a UI path. Read a counter you already maintain; do not run a report.

Do not put a whole feature in the dropdown. The dropdown is a glance and a shortcut. If the user needs to work in it, give them a panel and let the dropdown link to it.

Do not claim zone="system" for an app-level indicator. See the warning above.

Do not assume your order wins. Other extensions default to 100 too. Order guarantees a relationship between your items, not a position among everyone else's.


Manifest output

A declared tray item lands in manifest["tray"]:

{
  "tray": [
    {
      "tray_id": "unread",
      "icon": "Mail",
      "tooltip": "Unread messages",
      "zone": "status",
      "order": 100
    }
  ]
}

zone and order are optional in the schema, so manifests written before 5.10.0 validate unchanged and the host defaults a missing zone to "status".

A tray item alone is enough to publish

Since 5.10.0 an extension that contributes only a tray item — no panel at all — publishes its UI surface. Before that the publisher returned early when there were no panel slots and the declaration was dropped without a trace.


Cross-references

On this page