Imperal Docs
SDK Reference

Complete API surface

Every SDK element on a single scrollable page — decorators, ctx, manifest, validators, action types, errors, invariants

This is the single-page exhaustive reference of every public element in the current imperal-sdk line. Use Cmd-F to find anything fast. Each element links to its dedicated deep-dive page. Version-pinned history lives in the changelog.

Quick index


Extension class

The top-level declaration in every extension. First positional argument is app_id.

from imperal_sdk import Extension

ext = Extension(
    "my-app",                      # app_id — positional
    version="1.0.0",
    display_name="My App",
    description="A longer description — at least 40 characters (V14).",
    icon="icon.svg",
    actions_explicit=True,
    capabilities=["my-app:read", "my-app:write"],
)

Prop

Type

Concept page → · Federal contract →


@chat.function

The most-used decorator. Registers an LLM-callable tool on a ChatExtension instance.

from imperal_sdk import ChatExtension
from pydantic import BaseModel, Field

chat = ChatExtension(ext, tool_name="my-app", description="My App assistant")

class SendEmailParams(BaseModel):
    to: str = Field(description="Recipient email address")
    subject: str = Field(description="Email subject line")
    body: str = Field(description="Email body text")

@chat.function(
    description="Send an email message via the user's connected mail account.",
    action_type="write",
    chain_callable=True,
    effects=["email.send"],
    event="email.sent",
    id_projection=None,
)
async def send_email(ctx, params: SendEmailParams):
    # params is auto-validated Pydantic model
    return ActionResult.success({"sent": True}, summary="Email sent")

Prop

Type

Pydantic params auto-detection: if your handler has a typed parameter whose type is a BaseModel subclass, the SDK auto-detects it and emits its JSON schema in the manifest. Define the model at module scope (V17 — not inside the function).

Concept page → · Decorators reference →


@ext.skeleton

Registers a live data probe. Called by the web-kernel on a TTL schedule; result 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: must return {"response": {...}} where the dict contains scalar fields (counts, flags, short strings). The web-kernel's classifier reads the values directly.

Access guard: ctx.skeleton.get() raises SkeletonAccessForbidden outside @ext.skeleton context. Regular tools and panels must use ctx.store or ctx.cache instead.

Concept page →


@ext.panel

Registers a UI panel handler. Returns a UINode tree built with ui.* primitives.

@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

Full @ext.panel reference →


Allowed panel slots

Prop

Type

"overlay", "bottom", and "chat-sidebar" will not raise at build time but the current Imperal Panel frontend has no code path to render them. Use slot="center" for center-overlay behavior. The "main" slot was removed in SDK 3.4.0 — use "center".


@ext.schedule

Registers a cron-driven background job. The web-kernel runs it per the cron expression; the handler receives a system context (ctx.user.imperal_id == "__system__").

@ext.schedule("nightly_archive", cron="0 3 * * *")
async def nightly_archive(ctx):
    # Fan-out pattern: iterate all installed users
    async for user_id in ctx.store.list_users():
        user_ctx = ctx.as_user(user_id)
        # ... do per-user work

Prop

Type

System context only. ctx.store.list_users() and ctx.as_user() are only available when ctx.user.imperal_id == "__system__". Regular tool contexts will raise RuntimeError.


@ext.webhook

Registers a webhook receiver endpoint.

@ext.webhook("/incoming", method="POST", secret_header="X-Webhook-Secret")
async def receive_webhook(ctx, headers: dict, body: str, query_params: dict):
    # HMAC verification must be done manually — no SDK helper
    sig = headers.get("X-Webhook-Secret", "")
    # ... verify and process
    return {"ok": True}

Prop

Type

There is no ctx.verify_webhook_secret() helper. The secret_header param only declares the header name. Your handler must implement HMAC verification manually using hmac.compare_digest().


@ext.on_event / @ext.emits

Subscribe to or declare platform events.

# Subscribe
@ext.on_event("email.received")
async def handle_email(ctx, event):
    # ctx.http.* is NOT available here — minimal context only
    # ctx.store and ctx.notify are available
    await ctx.notify(f"New email from {event.data.get('from')}")

# Declare emission (must prefix with own app_id)
@ext.emits("my-app.item.created")
async def _declare_item_created(): ...

Prop

Type

@ext.on_event handlers receive a minimal context. Calling ctx.* methods (http, store, etc.) inside an event handler raises AttributeError. Only ctx.store, ctx.notify, and ctx.ai are reliably available.


@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):
    pass

# Then in a handler:
cached = await ctx.cache.get("inbox", InboxSummary)
if cached is None:
    fresh = await fetch_inbox_summary(ctx)
    await ctx.cache.set("inbox", fresh, ttl_seconds=60)

The model class must be a BaseModel subclass. Duplicate names within the same extension raise ValueError.


@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": "..."}

@ext.expose is in the SDK and emits to the manifest but has zero production usage. The Auth GW routing mechanism and cross-tenant IPC limitations are undocumented. Mark as experimental.


@ext.tray

Declares a system tray item. Handler returns a UINode tree with badge and optional dropdown.

@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. Behavior in the Imperal Panel frontend is not verified.


@ext.secret

Declares a per-user encrypted credential the extension needs. Federal EXT-SECRETS-V1 (SDK v4.2.2+). Plaintext is encrypted by the platform KMS (AES-256-GCM, non-exportable key), stored as ciphertext in the encrypted-secrets store, and is never present in logs, journals, error responses, chat history, workflow event history, or backups.

ext.secret(
    name="openai_api_key",
    description="Your OpenAI API key (sk-proj-...).",
    required=True,
    write_mode="user",         # 'user' | 'extension' | 'both'
    max_bytes=200,
    rotation_hint_days=90,
)(lambda: None)

Prop

Type

Auto-registered Secrets panel (v4.2.4+): every Extension instance unconditionally registers a synthetic secrets panel in __init__ (slot right, title Secrets, icon KeyRound). Users land there to manage credentials without the author writing any panel code. The synthetic tool name is __panel__secrets and is excluded from validator tool_count (v4.2.5+).

Full @ext.secret reference →


Lifecycle hooks

Lifecycle hooks are registered as decorators directly on the Extension instance (no arguments for most).

@ext.on_install
async def on_install(ctx):
    # ctx.user is the installing user
    await ctx.store.create("settings", {"theme": "dark"})

@ext.on_upgrade("2.0.0")
async def on_upgrade_2_0_0(ctx):
    # Runs when user upgrades from any version to 2.0.0
    await ctx.store.update("settings", "main", {"migrated": True})

@ext.on_uninstall
async def on_uninstall(ctx):
    pass  # cleanup

@ext.on_enable
async def on_enable(ctx):
    pass

@ext.on_disable
async def on_disable(ctx):
    pass

@ext.health_check
async def health_check(ctx):
    # Called every 60s by web-kernel
    return HealthStatus(healthy=True)
HookSignatureWhen called
@ext.on_install(ctx)First install by a user
@ext.on_upgrade(version)(ctx)User upgrades to that semver
@ext.on_uninstall(ctx)User uninstalls
@ext.on_enable(ctx)Admin enables for user/tenant
@ext.on_disable(ctx)Admin disables
@ext.health_check(ctx)Every 60s by web-kernel

ctx (Context)

The single object every handler receives. It is a dataclass built by the web-kernel after auth + tenant + scope checks pass.

async def my_handler(ctx, params):
    ctx.user.imperal_id   # str — canonical user ID (W1 canonical)
    ctx.user.email        # str | None
    ctx.user.tenant_id    # str
    ctx.user.role         # str
    ctx.user.scopes       # list[str]
    ctx.user.attributes   # dict — arbitrary admin-set attributes
    ctx.tenant            # TenantContext | None
    ctx.store             # StoreProtocol | None — per-user document store
    ctx.db                # DBProtocol | None — raw SQL access
    ctx.ai                # AIProtocol | None — LLM completion
    ctx.skeleton          # _SkeletonAccessGuard — only in @ext.skeleton context
    ctx.billing           # BillingProtocol | None
    ctx.notify            # NotifyProtocol | None — push/in-app notifications
    ctx.storage           # StorageProtocol | None — file storage
    ctx.http              # HTTPProtocol | None — outbound HTTP
    ctx.tools             # ToolsProtocol | None — cross-tool discovery/call
    ctx.config            # ConfigProtocol | None — extension config
    ctx.extensions        # ExtensionsProtocol | None — IPC + event emit
    ctx.cache             # CacheClient (property) — short-lived Pydantic cache
    ctx.time              # TimeContext — timezone, utc_offset, now_utc, now_local
    ctx.agency_id         # str | None
    ctx.agency_theme      # dict | None

Prop

Type

ctx.lang does not exist

There is no ctx.lang attribute. Language detection is handled at the web-kernel classifier layer. If your extension needs the user's language, read it from ctx.user.attributes or from skeleton data.

Always key state by ctx.user.imperal_id

Never key by display_name, email, or any other field. imperal_id is the federal canonical identifier. Multi-tenant safety depends on this.

ctx.store methods

doc    = await ctx.store.create("collection", {"key": "value"})
doc    = await ctx.store.get("collection", doc_id)
page   = await ctx.store.query("collection", where={"status": "active"}, limit=100)
doc    = await ctx.store.update("collection", doc_id, {"key": "new_value"})
ok     = await ctx.store.delete("collection", doc_id)
count  = await ctx.store.count("collection", where={"status": "active"})
# System context only:
async for user_id in ctx.store.list_users(): ...
page   = await ctx.store.query_all("collection", where={...})

ctx.http methods

resp = await ctx.http.get("https://api.example.com/data")
resp = await ctx.http.post("https://api.example.com/items", json={...})
resp = await ctx.http.put("https://api.example.com/items/1", json={...})
resp = await ctx.http.patch("https://api.example.com/items/1", json={...})
resp = await ctx.http.delete("https://api.example.com/items/1")
# resp.status_code, resp.json(), resp.text, resp.headers

# Per-call timeout override (v4.2.12+). Default 30s; federal cap 180s.
resp = await ctx.http.post("https://api.example.com/v1/...",
                           json={...}, timeout=120)

Federal cap 180s — I-LONGRUN-HTTP-CAP-180S

The timeout= kwarg is capped at 180 seconds. Anything larger raises ValueError("ctx.http timeout {N}s exceeds federal cap (180s)..."). For ops that legitimately exceed 3 minutes, use ctx.background_task() instead.

The HTTP client on ctx is ctx.http, not ctx.api. There is no ctx.api attribute. Earlier docs incorrectly used ctx.api — this was a fabrication.

ctx.background_task() — LONGRUN-V1 (SDK v4.2.12+)

# Spawn a coroutine in the background; result auto-delivered to chat
# when the coroutine completes.
task_id: str = await ctx.background_task(
    coro=_my_long_op(),
    long_running=False,    # False → 180s cap, True → 1800s cap
    name="AI refinement",  # human-readable for UI/audit
)
return ActionResult.success(
    summary="Got it — running in background. I'll send the result here.",
    data={"task_id": task_id},
)

The coroutine MUST return ActionResult — federal I-LONGRUN-BG-CORO-RETURNS-ACTIONRESULT. Returning anything else triggers a critical audit row + delivers a fallback error to chat. The user sees two chat turns:

  1. Immediate: the caller's ActionResult.success(summary="Got it — running…") ack.
  2. Later (when coro finishes): the coro's ActionResult rendered as a fresh bot message.

Use this when a single chat handler would otherwise take longer than the 180-second ctx.http cap, when you want chat to stay unblocked while work runs, or when the result delivery time is variable.

ctx.deliver_chat_message() — LONGRUN-V1 (SDK v4.2.12+)

# Inject a bot message into the user's chat at any time.
# Mirrors the web-kernel's auto-promote chat-injection path, but you call it.
await ctx.deliver_chat_message(
    "Spotify connected! 🎵 You can now ask what's playing or search your library.",
    msg_type="response",       # "response" | "system" | "tool_result"
    refresh_panels=["library"], # optional — panel_ids to re-render after
)

Common use cases:

  • OAuth callback acknowledgement — webhook handler tells the user the link succeeded.
  • Multi-stage background announcements — background work emits intermediate progress as separate bot messages before the final result.
  • One-off proactive notifications that don't fit the periodic skeleton-alert pattern.

Text truncated to 64KB with …(truncated) marker if larger. Federal: I-LONGRUN-CHAT-INJECT-USER-SCOPED (cross-user inject returns 403) + I-LONGRUN-CHAT-INJECT-AUDIT-EVERY (every inject writes an audit row).

ctx.cache methods

value = await ctx.cache.get("key", MyModel)             # returns MyModel | None
await ctx.cache.set("key", value, ttl_seconds=60)       # TTL: 5-300s
await ctx.cache.delete("key")
value = await ctx.cache.get_or_fetch("key", MyModel, fetcher, ttl_seconds=60)

Key format: ^[A-Za-z0-9_\-:]+$, max 128 chars.

ctx.as_user(user_id) — system context only

# Only works when ctx.user.imperal_id == "__system__"
user_ctx = ctx.as_user("imp_u_tE-J9c_NxX")

Returns a new Context scoped to the given user. Used in @ext.schedule handlers for per-user fan-out.

ctx.progress(percent, message="")

await ctx.progress(50.0, "Halfway done...")
# May raise TaskCancelled if user cancelled

ctx.secrets methods — EXT-SECRETS-V1 (SDK v4.2.2+)

# Read plaintext for one declared secret. Returns None if declared but unset.
api_key: str | None = await ctx.secrets.get("spotify_api_key")

# Write plaintext. Allowed only if manifest write_mode is 'extension' or 'both'.
await ctx.secrets.set("spotify_refresh_token", "rt-...")

# Delete (same write_mode rules apply).
was_set: bool = await ctx.secrets.delete("spotify_refresh_token")

# Cheap metadata read — does NOT decrypt, does NOT write an audit row.
is_set: bool = await ctx.secrets.is_set("spotify_api_key")

# List all declared secrets for this extension. Returns SecretStatus dataclasses
# with name, description, is_set, last_accessed_at — NEVER the value.
statuses = await ctx.secrets.list()

Federal contract (full spec in @ext.secret reference):

  • ctx.secrets.get/set/delete/is_set on a name not in your manifest's secrets[] raises SecretNotDeclaredError — manifest is single source of truth (I-SECRETS-CONTRACT-DECLARED).
  • Plaintext exists only inside the handler call stack. SDK has no module-level cache, no class-level cache, no @lru_cache. Storing plaintext in instance/module state violates I-SECRETS-HANDLER-SCOPE-MEMORY.
  • Platform KMS endpoint unavailable raises SecretVaultUnavailable — fail-closed (I-SECRETS-VAULT-DEPENDENCY).
  • Setting a secret whose write_mode='user' raises SecretWriteForbidden. Only Panel UI (user-attributable session) can write user-mode secrets.

Dev mode (IMPERAL_DEV_MODE=true):

  • get(name) reads IMPERAL_SECRET_<UPPER_NAME> env var (or returns None).
  • set/delete are no-ops with WARN log.
  • Manifest contract still enforced — undeclared names still raise.

pytest fixture pattern:

import pytest
from imperal_sdk.testing import MockSecretStore

@pytest.fixture
def secrets():
    return MockSecretStore({"spotify_api_key": "sk-test"})

async def test_search(ctx_factory, secrets):
    ctx = ctx_factory(secrets=secrets)
    ...

Panel UI write paths must use ui.Password (v4.2.6+) — <input type="password" autocomplete="new-password" spellcheck="false"> masks the value while the user types and suppresses browser autofill. The plaintext still travels in the POST body to the platform secrets endpoint, which is the only correctness boundary. See ui.Password.

ctx.config is NOT for credentials. Use ctx.config for non-sensitive settings (default timezone, feature flags, display preferences). Credentials — API keys, OAuth tokens, webhook signing secrets — MUST flow through ctx.secrets. ctx.config values are stored plaintext in the user store and visible to the Panel; only ctx.secrets is Vault-encrypted, audit-chokepoint-routed, and federal-grade.

ctx.webhook_url — SDK v4.2.7+

Build the canonical public OAuth/webhook callback URL for this extension.

url = ctx.webhook_url("/callback")
# → "https://panel.imperal.io/v1/ext/spotify/webhook/callback"

Leading slash optional. Multi-segment paths supported.

Returns https://{IMPERAL_PUBLIC_HOST}/v1/ext/{web-kernel-authoritative-app_id}/webhook/{path}. Default host panel.imperal.io. The {app_id} component is the folder/manifest name (the value auth-gw and Vault know about), not the Python Extension("X", ...) value — if those drift, this helper still returns the correct deployed URL.

Use it instead of hardcoding SP_REDIRECT_URI = "https://..." in *_config.py — hardcoded URLs are the #1 cause of OAuth-callback drift bugs (auth-gw 401 / provider redirect URI mismatch). See @ext.webhook reference.


ActionResult

The return type for all @chat.function handlers.

from imperal_sdk import ActionResult

# Success
return ActionResult.success({"note_id": "123"}, summary="Note created")
return ActionResult.success(data, ui=ui.Stack([...]))
return ActionResult.success(data, refresh_panels=["sidebar", "editor"])

# Error
return ActionResult.error("Something went wrong", retryable=True)
return ActionResult.error("Permanent failure", retryable=False)

Prop

Type

Never expose raw exception strings in ActionResult.error(). The web-kernel's Magic UX layer (I-MAGIC-UX-1/2) formats errors via i18n templates — your error message should be user-facing prose, not str(e) or Pydantic class names.


Manifest fields (imperal.json)

Auto-generated by imperal build ./extension. Do NOT hand-edit.

{
  "manifest_schema_version": 3,
  "app_id": "my-app",
  "version": "1.0.0",
  "name": "My App",
  "description": "A longer description — at least 40 characters.",
  "icon": "icon.svg",
  "actions_explicit": true,
  "capabilities": ["my-app:read", "my-app:write"],
  "tools": [...],
  "signals": [...],
  "schedules": [...],
  "required_scopes": [...],
  "webhooks": [...],
  "events": {"subscribes": [...], "emits": [...]},
  "exposed": [...],
  "lifecycle": {...},
  "lifecycle_hooks": {...},
  "tray": [...]
}

Prop

Type

tools[] (FunctionDef) — typed chat.function entries

Prop

Type

Manifest reference →


Federal validators

All ERROR severity. Failing any blocks publishing via Developer Portal. Run with imperal validate ./extension.

Prop

Type

V23 was dropped in v4.0.1 as redundant.

Validators reference → · Federal contract →


Action types

Prop

Type


Error codes

Prop

Type

Error codes reference →


Federal invariants (selected)

Prop

Type

Federal invariants concept page →


CLI commands

imperal validate ./extension          # run V14-V22+V24+V31
imperal build ./extension # generate imperal.json
python3 -m py_compile app.py          # syntax check before deploy

Also available via Python:

from imperal_sdk import generate_manifest, save_manifest, validate_extension
manifest = generate_manifest(ext)
save_manifest(ext, ".")
report = validate_extension(ext)

Compatibility matrix

Prop

Type


Where to dig deeper

On this page