Decorators reference
Every SDK decorator and what it accepts
A reference for every decorator the SDK ships. Each one registers a different surface to the web-kernel. All signatures track the latest imperal-sdk source — for the version-pinned release history see the changelog.
@chat.function
The most-used decorator. Registered on a ChatExtension instance (not directly on Extension).
from imperal_sdk import Extension, ChatExtension, ActionResult
from pydantic import BaseModel, Field
ext = Extension("my-app", display_name="My App", description="...", icon="icon.svg", actions_explicit=True)
chat = ChatExtension(ext, tool_name="my-app", description="My App assistant")
class CreateTaskParams(BaseModel):
title: str = Field(description="Task title — what needs to be done")
due_at: str | None = Field(None, description="Optional ISO datetime when the task is due")
@chat.function(
description="Create a new task for the user.",
action_type="write",
chain_callable=True,
effects=["task.create"],
event="task.created",
id_projection=None,
)
async def create_task(ctx, params: CreateTaskParams):
doc = await ctx.store.create("tasks", {"title": params.title})
return ActionResult.success({"task_id": doc.id}, summary=f"Created task: {params.title}")Prop
Type
Pydantic params: define the model at module scope (not inside the function) — V17 enforces this. The SDK auto-detects a BaseModel typed parameter and emits its JSON schema in the manifest.
Return type: always return ActionResult.success(data, summary) or ActionResult.error(msg, retryable). Never return bare dicts from destructive/write tools.
@ext.skeleton
Registers a live data probe. The web-kernel calls it on a TTL schedule; the result is stored as LLM context for the intent classifier.
@ext.skeleton("tasks_summary", alert=True, ttl=300)
async def refresh_tasks_skeleton(ctx) -> dict:
total = await ctx.store.count("tasks")
pending = await ctx.store.count("tasks", where={"status": "pending"})
return {"response": {
"total": total,
"pending": pending,
}}Prop
Type
Return contract: {"response": {...}} where the dict contains flat scalar values. The intent classifier reads them directly.
Access guard: ctx.skeleton.get() raises SkeletonAccessForbidden outside @ext.skeleton context (invariant I-SKELETON-LLM-ONLY). Regular tools and panels must use ctx.store or ctx.cache.
@ext.panel
Registers a UI panel handler. The web-kernel exposes your handler as a callable endpoint (__panel__{panel_id}) that the Imperal Panel frontend fetches on load and on explicit re-fetch. Returns a UINode tree built with ui.* primitives.
from imperal_sdk import Extension, ui
ext = Extension("my-app", ...)
@ext.panel(
"sidebar",
slot="left",
title="My Sidebar",
icon="📜",
refresh="on_event:my-ext.item.created,my-ext.item.updated",
default_width=280,
min_width=200,
max_width=500,
)
async def sidebar(ctx, **params):
items = await ctx.store.query("items")
if not items.data:
return ui.Empty(message="No items yet.")
return ui.List(items=[ui.Text(i.data["label"]) for i in items.data])Prop
Type
Slot allowlist
| Slot | Description | Frontend status |
|---|---|---|
"center" | Default. Middle content area. | Active |
"left" | Left sidebar. Resizable. | Active |
"right" | Right sidebar. Resizable. | Active |
"overlay" | Accepted by SDK — no frontend render path. | Frontend-dead |
"bottom" | Accepted by SDK — no frontend render path. | Frontend-dead |
"chat-sidebar" | Accepted by SDK — no frontend render path. | Frontend-dead |
"overlay", "bottom", and "chat-sidebar" will not raise at build time but the current Imperal Panel frontend has no code path to render them — panels with these slots will silently never be fetched. The "main" slot was removed in SDK 3.4.0 — use "center".
For slot="center" to render, you must also pass center_overlay=True (federal v4.1.8+) and trigger the panel via auto_action from your sidebar's root UINode. See concepts/panels.mdx § How slot="center" actually activates for the full wire-up and the I-PANEL-RENDERING-CONTRACT federal contract.
Refresh modes
| Value | Behavior |
|---|---|
"manual" (default) | No automatic re-fetch. Panel fetched once on load, then only on ActionResult.refresh_panels. |
"on_event:scope.action[,...]" | SSE subscriber re-fetches when any listed event arrives. |
"interval:Ns" | Frontend interval polling (must be injected manually via admin API — web-kernel publisher ignores it). |
refresh_seconds=N does not exist as a named parameter on @ext.panel. Passing it lands in **kwargs and is silently ignored by the web-kernel publisher. Use refresh="on_event:..." or refresh="interval:Ns".
Handler signature
@ext.panel("detail", slot="right", title="Detail", default_width=320)
async def detail(ctx, **params):
item_id: str = params.get("item_id", "")
if not item_id:
return ui.Empty(message="Select an item.")
doc = await ctx.store.get("items", item_id)
return ui.Stack(direction="v", children=[
ui.Text(doc.data["title"]),
])Returns: any ui.* primitive. Returning None is valid Python but use ui.Empty(message="...") so the user sees a clear message.
See also
- Panels concept — slot ownership, lifecycle, refresh
- Panel layouts guide
- Panel troubleshooting guide
- Recipe: master-detail pattern
- Recipe: center-overlay
- Recipe: auto-action on load
- Recipe: refresh after write
@ext.schedule
Registers a cron-driven background job. Handler runs in system context.
@ext.schedule("nightly_archive", cron="0 3 * * *")
async def nightly_archive(ctx):
async for user_id in ctx.store.list_users():
user_ctx = ctx.as_user(user_id)
await process_user(user_ctx)Prop
Type
System context: the handler receives a system context (ctx.user.imperal_id == "__system__"). ctx.store.list_users() and ctx.as_user() are only available in system context.
@ext.webhook
Registers a webhook receiver. Auth Gateway routes incoming POST to your handler.
import hmac, hashlib
@ext.webhook("/incoming", method="POST", secret_header="X-Hub-Signature-256")
async def receive_webhook(ctx, headers: dict, body: str, query_params: dict):
# Verify HMAC manually — no SDK helper
sig = headers.get("X-Hub-Signature-256", "")
secret = ctx.config.require("webhook_secret")
expected = "sha256=" + hmac.new(
secret.encode(), body.encode(), hashlib.sha256
).hexdigest()
if not hmac.compare_digest(sig, expected):
return {"error": "Invalid signature"}
payload = json.loads(body)
# process...
return {"ok": True}Prop
Type
Handler receives: ctx (minimal, ctx.user.imperal_id == "__webhook__"), headers: dict, body: str (raw request body), query_params: dict.
There is no ctx.verify_webhook_secret() helper. secret_header only names the header. Your handler must call hmac.compare_digest() manually.
@ext.on_event
Subscribes to a platform event. Handler called when the event fires.
@ext.on_event("email.received")
async def handle_email_received(ctx, event):
subject = event.data.get("subject", "")
await ctx.notify(f"New email: {subject}")Prop
Type
Handler receives: ctx (minimal context), event (EventModel with .type, .data).
Event handlers receive a minimal context. ctx.http / ctx.ai / ctx.db may raise AttributeError. Only ctx.store, ctx.notify, and basic identity attributes are reliably available.
@ext.emits
Declares an event this extension publishes. Powers the manifest events.emits[] section.
@ext.emits("my-app.item.created", schema_ref="ItemCreatedEvent")
async def _declare_item_created(): ...
@ext.emits("my-app.item.deleted")
async def _declare_item_deleted(): ...Prop
Type
The decorated function is a dummy placeholder — the decorator only registers the declaration, it does not call the function.
Actually emit events at runtime via ctx.extensions.emit("my-app.item.created", {"id": "..."})
@ext.cache_model
Registers a Pydantic model as a valid value shape for ctx.cache.
from pydantic import BaseModel
class InboxSummary(BaseModel):
unread: int
latest_subject: str
@ext.cache_model("inbox_summary")
class _InboxSummary(InboxSummary):
passProp
Type
Constraint: cls must be a pydantic.BaseModel subclass (raises TypeError). Duplicate names raise ValueError. The model is scoped to the extension instance.
@ext.expose
Exposes a method for inter-extension calls via ctx.extensions.call().
@ext.expose("get_summary", action_type="read")
async def get_summary(ctx, **kwargs):
return {"summary": "..."}Prop
Type
@ext.expose is experimental. Zero production usage. Auth GW routing mechanism and cross-tenant IPC limitations are not fully documented.
@ext.tray
Declares a system tray item. Handler returns a UINode tree.
@ext.tray("unread", icon="📧", tooltip="Unread messages")
async def tray_unread(ctx, **kwargs):
count = await ctx.store.count("messages", where={"read": False})
return ui.Stack([ui.Badge(str(count), color="red" if count > 0 else "gray")])Prop
Type
@ext.tray is experimental with no production usage.
@ext.secret
Declarative per-user encrypted credential declaration (SDK v4.2.2+). Federal EXT-SECRETS-V1 API — extension declares a secret it needs (API key, OAuth refresh token, webhook signing secret) and reads the plaintext via ctx.secrets.get(). Storage is AES-256-GCM ciphertext via the platform KMS (non-exportable key); plaintext never lands in the audit ledger, logs, or backups.
ext.secret(
name="spotify_api_key",
description="Your Spotify API key (from developer.spotify.com).",
required=True,
write_mode="user",
max_bytes=200,
)(lambda: None)
@chat.function
async def search(ctx, query: str):
api_key = await ctx.secrets.get("spotify_api_key")
# Use api_key for the duration of this handler call only.
...Prop
Type
Returns an identity decorator — the call itself registers the SecretSpec. See full @ext.secret reference for usage patterns, dev-mode, pytest fixtures, and the seven federal invariants enforced by this surface.
@ext.tool
Low-level tool registration. Prefer @chat.function for LLM-callable tools. Use @ext.tool for platform-internal tools (e.g., the skeleton alert companion).
@ext.tool("skeleton_alert_tasks", scopes=[], description="Alert when tasks change.")
async def skeleton_alert_tasks(ctx):
return {"changed": True}Prop
Type
Lifecycle hooks
Applied directly as decorators on the Extension instance.
@ext.on_install
async def on_install(ctx):
await ctx.store.create("settings", {"theme": "dark"})
@ext.on_upgrade("2.0.0")
async def upgrade_to_2_0(ctx):
await ctx.store.update("settings", "main", {"schema_v": 2})
@ext.on_uninstall
async def on_uninstall(ctx): ...
@ext.on_enable
async def on_enable(ctx): ...
@ext.on_disable
async def on_disable(ctx): ...
@ext.health_check
async def health_check(ctx):
return HealthStatus(healthy=True)| Decorator | Signature | When |
|---|---|---|
@ext.on_install | (ctx) | First install |
@ext.on_upgrade(version) | (version) | User upgrades to that semver |
@ext.on_uninstall | (ctx) | User uninstalls |
@ext.on_enable | (ctx) | Admin enables |
@ext.on_disable | (ctx) | Admin disables |
@ext.health_check | (ctx) | Every 60s by web-kernel |
Decorators that DON'T exist
The following are not in the SDK and were erroneously listed in earlier documentation:
| Decorator | Reality |
|---|---|
@ext.schedule | Does not exist. Use @ext.schedule (no 'd'). |
@ext.secret | Does not exist yet. Planned as EXT-SECRETS-V1 (not shipped). |
@ext.oauth_provider | Does not exist. |
@panels.sidebar | Does not exist. Use @ext.panel(..., slot="left"). |
@panels.editor | Does not exist. Use @ext.panel(..., slot="center", center_overlay=True). |
@panels.settings | Does not exist. |
@panels.action | Does not exist. |
@cache | Does not exist. Use @ext.cache_model + ctx.cache. |
@auth_required | Does not exist. Auth always runs before dispatch. |
@require_scope | Does not exist. Scopes declared in manifest, enforced before dispatch. |