Imperal Docs
Guides

Error handling idioms

Magic UX errors, ActionResult.error, retry semantics, exception types, audit-clean failures

Every @chat.function handler runs inside an ICNLI Worker activity. The web-kernel records the outcome in the audit ledger and delivers the result to the user's chat session — there is no second chance if you handle failures carelessly. The same message that goes to the user also becomes part of the audit trail. This guide covers every layer of the failure path so you can write handlers that fail gracefully, informatively, and without leaking internals.

TopicSection
Two failure pathsPaths
ActionResult.error() referenceReference
Magic UX ruleMagic UX
Retryable vs non-retryableRetry semantics
Exception classesExceptions
try/except patternsPatterns
LoggingLogging
Validation errorsValidation
External API failuresHTTP
Store / cache failuresStore
Cross-extension callsIPC
Webhook handlersWebhooks
Scheduled jobsSchedules
Event handlersEvents
Common pitfallsPitfalls
Cross-referencesSee also

Why error handling matters in extensions

Extension handlers touch real user data, charge the billing ledger, and emit audit entries on every invocation. The web-kernel fails closed: if a handler raises an unhandled exception, the call is marked as failed in the ledger and the user receives a generic error message — the failed state is final. There is no automatic retry for business-logic failures.

Three platform rules make error handling a first-class concern:

  1. Magic UX (I-MAGIC-UX-1 / I-MAGIC-UX-2) — raw exception strings must never reach the user. The web-kernel intercepts unhandled Pydantic validation errors and formats them as structured prose, but for any other failure the extension is responsible.
  2. Audit ledger — the error field in ActionResult is stored verbatim. Write stable, user-safe strings — not stack traces.
  3. Federal observability — structured logs from ctx.log are collected in the extension dashboard. Unhandled exceptions produce less useful entries than explicit log + return.

The two failure paths

There are exactly two ways to report a failure from a @chat.function handler:

PathWhen to useWhat the user sees
return ActionResult.error(...)Expected failures — validation, not-found, quota, network errorsYour error string, formatted by the web-kernel
Raise an unhandled exceptionTruly unexpected bugs onlyGeneric "something went wrong" — no detail

Prefer ActionResult.error() for every failure you can anticipate. Reserve unhandled exceptions for genuine programming errors that indicate a bug you want surfaced in logs immediately.

handlers_notes.py
from pydantic import BaseModel
from imperal_sdk import ActionResult
from imperal_sdk.errors import NotFoundError


class GetNoteParams(BaseModel):
    note_id: str


async def fn_get_note(ctx, params: GetNoteParams) -> ActionResult:
    # Graceful path — return ActionResult.error for expected failures
    if not params.note_id:
        return ActionResult.error("note_id is required")

    doc = await ctx.store.get("notes", params.note_id)
    if doc is None:
        return ActionResult.error(f"Note '{params.note_id}' not found")

    return ActionResult.success(
        data={"note_id": doc.id, "title": doc.data.get("title", "")},
        summary="Note retrieved",
    )

ActionResult.error() reference

from imperal_sdk import ActionResult

# Factory signature (source: imperal_sdk.types.action_result)
result = ActionResult.error("Something went wrong", retryable=False)

Parameters:

ParameterTypeDefaultPurpose
errorstrrequiredUser-facing message. Must not contain raw exception strings, class names, or internal paths.
retryableboolFalseWhen True, the platform surfaces a retry affordance in the UI.

Wire contract:

  • status is always "error"
  • data is always {} — do not try to attach payload to an error result
  • summary is always "" — the error field is what the user sees
  • retryable is only serialized when True

When to set retryable=True:

Set retryable=True only when the same call with the same parameters has a reasonable chance of succeeding if retried — typically transient network failures or temporary rate limits. Do not set it for permanent failures such as validation errors, not-found responses, or permission denials.

handlers_mail.py
from pydantic import BaseModel
from imperal_sdk import ActionResult


class FetchInboxParams(BaseModel):
    limit: int = 25


async def fn_fetch_inbox(ctx, params: FetchInboxParams) -> ActionResult:
    try:
        resp = await ctx.http.get(
            "https://mail-api.example.com/inbox",
            params={"limit": params.limit},
        )
    except Exception:
        # Transient — network timeout, DNS failure, etc.
        return ActionResult.error(
            "Could not reach the mail service. Please try again.",
            retryable=True,
        )

    if not resp.ok:
        if resp.status_code == 429:
            # Transient — rate limited
            return ActionResult.error(
                "Mail API rate limit reached. Please try again in a moment.",
                retryable=True,
            )
        # Permanent — bad credentials, server error, etc.
        return ActionResult.error(
            f"Mail API returned an error ({resp.status_code})."
        )

    return ActionResult.success(
        data={"messages": resp.json().get("messages", [])},
        summary="Inbox loaded",
    )

The Magic UX rule

Never put raw exception strings in user-facing text

The error field in ActionResult is shown directly to users in the chat interface. Raw Python exception strings (str(e)) often contain class names, internal paths, Pydantic URLs, and PII. Always write a stable, user-friendly message instead.

The Magic UX rule was established as part of the federal platform hardening (2026-05-01). It exists because str(pydantic_e) was surfacing schema URLs and field-path details directly in chat, which violated both privacy expectations and UX quality standards.

Wrong:

handlers_bad.py
from pydantic import BaseModel
from imperal_sdk import ActionResult


class CreateNoteParams(BaseModel):
    title: str


async def fn_create_note_bad(ctx, params: CreateNoteParams) -> ActionResult:
    try:
        result = await ctx.http.post("/notes", json={"title": params.title})
    except Exception as e:
        # ❌ str(e) may contain internal paths, class names, or PII
        return ActionResult.error(str(e))
    return ActionResult.success(data={}, summary="Done")

Correct:

handlers_good.py
from pydantic import BaseModel
from imperal_sdk import ActionResult
import logging

log = logging.getLogger(__name__)


class CreateNoteParams(BaseModel):
    title: str


async def fn_create_note_good(ctx, params: CreateNoteParams) -> ActionResult:
    try:
        resp = await ctx.http.post("/notes", json={"title": params.title})
    except Exception as exc:
        # ✅ Log the raw detail internally; show a stable message to the user
        log.error("create_note http failed: %s", exc)
        return ActionResult.error(
            "Could not create the note. Please try again.",
            retryable=True,
        )
    if not resp.ok:
        log.warning("create_note backend error %s", resp.status_code)
        return ActionResult.error("Note creation failed. Please check your input.")
    note_id = resp.json().get("id", "")
    return ActionResult.success(
        data={"note_id": note_id, "title": params.title},
        summary=f"Note '{params.title}' created",
        refresh_panels=["sidebar"],
    )

The two-step pattern — log.error(...) for operator visibility + ActionResult.error(user-safe message) for the user — is the canonical form throughout production extensions.


Retryable vs non-retryable

The retryable flag signals to the platform that retrying the exact same call is meaningful. When retryable=True, the chat UI renders a retry affordance. When retryable=False (the default), the user sees the error message with no retry button.

Scenarioretryable
Network timeout contacting external APITrue
HTTP 429 rate limit from external APITrue
HTTP 503 / 504 from external APITrue
Validation failure (title is required)False
Resource not found (404)False
Permission deniedFalse
Quota exceededFalse
Permanent configuration errorFalse

The web-kernel does not automatically retry on retryable=True. The flag purely controls whether the platform renders a retry affordance in the UI. You are responsible for idempotency if the user retries.


Exception classes

The SDK exports a hierarchy of exception classes from imperal_sdk.errors. These are raised by SDK clients — you catch them in your handlers. They are never raised directly from ActionResult.

ImperalError (base)

from imperal_sdk.errors import ImperalError

Base class for all SDK errors. Catch ImperalError to handle any SDK-raised error in a single except block. Fields: message: str, code: str.

APIError

Raised by any ctx.* client that makes an HTTP call to the platform gateway. Fields: message, code, status_code: int.

NotFoundError(APIError)

Raised when a platform resource returns 404. Fields: resource: str, id: str. Subclass of APIError.

RateLimitError(APIError)

Raised on HTTP 429 from the platform gateway. Fields: retry_after: int (seconds). Subclass of APIError.

AuthError

Raised when an operation fails authentication or authorization checks. Code: "auth_error".

ValidationError

Raised by ctx.config.require(key) when a required config key is absent. Fields: field: str.

ExtensionError

Raised for errors within extension execution infrastructure. Fields: app_id: str.

QuotaExceededError

Raised when the billing quota for a resource is exceeded. Fields: resource: str, limit: int.

CircularCallError(ExtensionError)

Raised by ctx.extensions.call() when a circular inter-extension dependency is detected. Fields: call_stack: list[str].

SkeletonAccessForbidden

Raised at runtime when ctx.skeleton.get() is called from any handler that is not decorated with @ext.skeleton. This enforces the I-SKELETON-LLM-ONLY invariant — skeleton data is the LLM classifier's input, not a data source for regular handlers.

from imperal_sdk.errors import SkeletonAccessForbidden

If you receive this error in tests, you are calling ctx.skeleton.get() from a tool, panel, or chat function. Move the read to a dedicated @ext.skeleton handler instead.

TaskCancelled

from imperal_sdk.chat import TaskCancelled

Raised by await ctx.progress(...) when the user cancels a long-running task. It is the only exception type you should routinely catch in @chat.function handlers.

handlers_export.py
from pydantic import BaseModel
from imperal_sdk import ActionResult
from imperal_sdk.chat import TaskCancelled
import logging

log = logging.getLogger(__name__)


class ExportParams(BaseModel):
    format: str = "csv"


async def fn_export_data(ctx, params: ExportParams) -> ActionResult:
    try:
        rows = await ctx.store.query("records")
        await ctx.progress(25, "Fetched records…")

        processed = []
        for i, row in enumerate(rows.data):
            processed.append(row.data)
            if i % 50 == 0:
                await ctx.progress(25 + int(50 * i / max(len(rows.data), 1)), "Processing…")

        await ctx.progress(90, "Formatting output…")
        # ... format and upload

    except TaskCancelled:
        # ✅ User cancelled — return a clean graceful message
        return ActionResult.error("Export cancelled")

    except Exception as exc:
        log.error("export failed: %s", exc)
        return ActionResult.error(
            "Export failed. Please try again.",
            retryable=True,
        )

    await ctx.progress(100, "Done")
    return ActionResult.success(
        data={"row_count": len(processed)},
        summary=f"Exported {len(processed)} records",
    )

Do not re-raise TaskCancelled. Return an ActionResult.error with a neutral message like "Cancelled" or "Export cancelled".


try/except patterns

Pattern A — single external call

The most common pattern: one external call, specific error translation.

handlers_tasks.py
from pydantic import BaseModel
from imperal_sdk import ActionResult
from imperal_sdk.errors import RateLimitError, APIError
import logging

log = logging.getLogger(__name__)


class CreateTaskParams(BaseModel):
    title: str
    project_id: int


async def fn_create_task(ctx, params: CreateTaskParams) -> ActionResult:
    try:
        resp = await ctx.http.post(
            "https://tasks.internal/tasks",
            json={"title": params.title, "project_id": params.project_id},
        )
    except RateLimitError as e:
        return ActionResult.error(
            f"Task service is rate-limited. Retry in {e.retry_after}s.",
            retryable=True,
        )
    except Exception as exc:
        log.error("create_task failed: %s", exc)
        return ActionResult.error(
            "Could not reach the task service. Please try again.",
            retryable=True,
        )

    if not resp.ok:
        return ActionResult.error(
            _translate_task_error(resp.json().get("detail", "Task creation failed"))
        )

    task_id = resp.json().get("id")
    return ActionResult.success(
        data={"task_id": task_id, "title": params.title},
        summary=f"Task '{params.title}' created",
        refresh_panels=["sidebar"],
    )


def _translate_task_error(detail: str) -> str:
    """Translate backend error details into user-friendly messages."""
    if "project not found" in detail.lower():
        return "Project not found. Please select a valid project."
    if "duplicate" in detail.lower():
        return "A task with this title already exists in the project."
    # Fall back to a generic message — do not surface raw backend detail
    return "Task creation failed. Please check your input."

The _translate_* helper pattern (seen in the tasks and sql-db extensions) keeps the error translation logic reusable and testable, and ensures you never accidentally forward a raw backend message to the user.

Pattern B — guard clause early exit

For validation that precedes any I/O, check and return before the try block.

handlers_notes.py
from pydantic import BaseModel, Field
from imperal_sdk import ActionResult


class UpdateNoteParams(BaseModel):
    note_id: str
    title: str = Field(default="")
    content: str = Field(default="")


async def fn_update_note(ctx, params: UpdateNoteParams) -> ActionResult:
    # Guard clauses — fast exits before any I/O
    if not params.note_id:
        return ActionResult.error("note_id is required")
    if not params.title and not params.content:
        return ActionResult.error("At least one of title or content is required")

    # Only reach I/O after guards pass
    try:
        resp = await ctx.http.patch(
            f"/notes/{params.note_id}",
            json={"title": params.title, "content": params.content},
        )
    except Exception:
        return ActionResult.error("Note update failed. Please try again.", retryable=True)

    if not resp.ok:
        if resp.status_code == 404:
            return ActionResult.error(f"Note '{params.note_id}' not found")
        return ActionResult.error("Note update failed")

    return ActionResult.success(
        data={"note_id": params.note_id},
        summary="Note updated",
        refresh_panels=["sidebar", "editor"],
    )

Pattern C — multi-step with partial failure

When a function performs several operations and a later step fails, decide whether to roll back or report partial success explicitly.

handlers_attachments.py
from pydantic import BaseModel
from imperal_sdk import ActionResult
import base64
import logging

log = logging.getLogger(__name__)


class UploadAttachmentParams(BaseModel):
    note_id: str
    filename: str
    data_base64: str


async def fn_upload_attachment(ctx, params: UploadAttachmentParams) -> ActionResult:
    if not params.note_id:
        return ActionResult.error("note_id is required")
    if not params.data_base64:
        return ActionResult.error("No file data received")

    # Step 1: decode — non-retryable, user input error
    try:
        raw_bytes = base64.b64decode(params.data_base64)
    except Exception:
        return ActionResult.error("Invalid file data (base64 decode failed)")

    # Step 2: upload — potentially transient
    try:
        file_info = await ctx.storage.upload(
            f"attachments/{params.note_id}/{params.filename}",
            raw_bytes,
        )
    except Exception as exc:
        log.error("attachment upload failed: %s", exc)
        return ActionResult.error(
            "File upload failed. Please try again.",
            retryable=True,
        )

    # Step 3: link to note — could fail without affecting the upload
    try:
        await ctx.store.update("notes", params.note_id, {
            "attachments": [{"path": file_info.path, "name": params.filename}],
        })
    except Exception as exc:
        log.warning("attachment link failed (file uploaded): %s", exc)
        # File is uploaded but not linked — surface this to the user
        return ActionResult.error(
            "File uploaded but could not be linked to the note. "
            "Please refresh and try attaching again."
        )

    return ActionResult.success(
        data={"path": file_info.path, "filename": params.filename},
        summary=f"Attached '{params.filename}'",
        refresh_panels=["editor"],
    )

Logging vs returning

Use both together: log for operator visibility, return for the user.

SituationWhat to do
Transient external failurelog.error(...) + return ActionResult.error(..., retryable=True)
Expected business failure (not-found, validation)return ActionResult.error(...) — no log needed unless debugging
Unexpected exceptionlog.error(...) + return ActionResult.error("unexpected error")
Slow path or suspicious inputlog.warning(...) — no error returned if handled

ctx.log is the structured logging surface that routes to the extension dashboard:

handlers_example.py
from pydantic import BaseModel
from imperal_sdk import ActionResult


class RunQueryParams(BaseModel):
    query: str


async def fn_run_query(ctx, params: RunQueryParams) -> ActionResult:
    await ctx.log(f"run_query called: user={ctx.user.imperal_id}", level="info")

    try:
        result = await ctx.http.post("/query", json={"q": params.query})
    except Exception as exc:
        await ctx.log(f"run_query failed: {exc}", level="error")
        return ActionResult.error("Query failed. Please try again.", retryable=True)

    return ActionResult.success(
        data={"rows": result.json().get("rows", [])},
        summary="Query completed",
    )

ctx.log(message, level="info") accepts "debug", "info", "warning", "error", "critical". Standard library logging.getLogger(__name__) also works and is more common in production extensions — both routes are visible in the extension dashboard.

ctx.log is an async def — remember to await it. Standard logging.getLogger(...) calls are synchronous and do not require await.


Validation errors

The SDK's Pydantic feedback loop (SDK v4.1.0+) handles PydanticValidationError automatically when the LLM supplies malformed arguments to a typed @chat.function. The web-kernel retries up to two times with structured prose feedback to the LLM — you do not need to catch PydanticValidationError yourself.

What this means in practice:

  • Do not wrap the entire handler body in try: ... except PydanticValidationError.
  • Do use Pydantic validators on your params model to encode business rules — they fire before your handler body.
  • Do return ActionResult.error(...) for validation that cannot be expressed in Pydantic (cross-field validation, database-state-dependent validation).
handlers_notes.py
from pydantic import BaseModel, Field, model_validator
from imperal_sdk import ActionResult
from typing import Optional


class CreateNoteParams(BaseModel):
    title: str = Field(..., min_length=1, max_length=500)
    folder_id: Optional[str] = None
    is_pinned: bool = False

    @model_validator(mode="after")
    def title_not_whitespace(self) -> "CreateNoteParams":
        if not self.title.strip():
            raise ValueError("title must not be blank")
        return self


async def fn_create_note(ctx, params: CreateNoteParams) -> ActionResult:
    # By here, Pydantic has already enforced title length and non-blank.
    # Only handle runtime / database-state validation manually.
    if params.folder_id:
        folder = await ctx.store.get("folders", params.folder_id)
        if folder is None:
            return ActionResult.error(
                f"Folder '{params.folder_id}' not found. "
                "List your folders to find the correct ID."
            )

    doc = await ctx.store.create("notes", {
        "title": params.title.strip(),
        "folder_id": params.folder_id,
        "is_pinned": params.is_pinned,
    })

    return ActionResult.success(
        data={"note_id": doc.id, "title": doc.data["title"]},
        summary=f"Created note '{doc.data['title']}'",
        refresh_panels=["sidebar"],
    )

External API failures

External API calls via ctx.http produce HTTPResponse objects. Inspect .ok and .status_code before reading .json().

handlers_campaigns.py
from pydantic import BaseModel
from imperal_sdk import ActionResult
from imperal_sdk.types import HTTPResponse
import logging

log = logging.getLogger(__name__)


class ListCampaignsParams(BaseModel):
    account_id: str


async def fn_list_campaigns(ctx, params: ListCampaignsParams) -> ActionResult:
    try:
        resp: HTTPResponse = await ctx.http.get(
            f"/accounts/{params.account_id}/campaigns",
        )
    except Exception as exc:
        log.error("list_campaigns transport error: %s", exc)
        return ActionResult.error(
            "Could not reach the campaigns service. Please try again.",
            retryable=True,
        )

    if resp.status_code == 404:
        return ActionResult.error(f"Account '{params.account_id}' not found")
    if resp.status_code == 401:
        return ActionResult.error(
            "Authentication failed. Please reconnect your account in settings."
        )
    if resp.status_code == 429:
        return ActionResult.error(
            "API rate limit reached. Please try again in a few minutes.",
            retryable=True,
        )
    if not resp.ok:
        log.warning("list_campaigns non-OK: %s", resp.status_code)
        return ActionResult.error(
            f"Campaigns API returned an error ({resp.status_code}). "
            "Please try again.",
            retryable=resp.status_code >= 500,
        )

    campaigns = resp.json().get("campaigns", [])
    return ActionResult.success(
        data={"campaigns": campaigns, "total": len(campaigns)},
        summary=f"Found {len(campaigns)} campaigns",
    )

Circuit breaker pattern — for extensions that call the same upstream service from multiple handlers, centralize the health check in a helper rather than duplicating try/except blocks:

app.py
from imperal_sdk.types import HTTPResponse
from imperal_sdk import ActionResult
import logging

log = logging.getLogger(__name__)


async def api_call(ctx, method: str, path: str, **kwargs) -> HTTPResponse:
    """Central HTTP wrapper. Raises on transport error; returns HTTPResponse otherwise."""
    try:
        fn = getattr(ctx.http, method)
        return await fn(path, **kwargs)
    except Exception as exc:
        log.error("api_call %s %s failed: %s", method.upper(), path, exc)
        raise


def is_no_connection_error(resp: dict) -> bool:
    """Return True when the bridge indicates the user has no account connected."""
    detail = resp.get("detail", "")
    return isinstance(detail, str) and "connect" in detail.lower()

This pattern (directly from the tasks extension's app.py) keeps transport error handling in one place and lets individual handlers focus on business logic.


Store and cache failures

ctx.store and ctx.cache can fail if the platform backend is temporarily unavailable. Both are async and can raise APIError or generic Exception.

handlers_store.py
from pydantic import BaseModel, Field
from imperal_sdk import ActionResult
from imperal_sdk.errors import APIError
import logging

log = logging.getLogger(__name__)


class SaveSettingsParams(BaseModel):
    theme: str = Field(default="system")
    notifications_enabled: bool = True


async def fn_save_settings(ctx, params: SaveSettingsParams) -> ActionResult:
    settings_data = {
        "theme": params.theme,
        "notifications_enabled": params.notifications_enabled,
    }

    try:
        existing = await ctx.store.get("settings", "user_prefs")
        if existing:
            await ctx.store.update("settings", "user_prefs", settings_data)
        else:
            await ctx.store.create("settings", settings_data)
    except APIError as exc:
        log.error("save_settings store error %s: %s", exc.status_code, exc.message)
        return ActionResult.error(
            "Settings could not be saved. Please try again.",
            retryable=True,
        )
    except Exception as exc:
        log.error("save_settings unexpected: %s", exc)
        return ActionResult.error("Settings could not be saved.")

    return ActionResult.success(
        data=settings_data,
        summary="Settings saved",
        refresh_panels=["settings"],
    )

For ctx.cache, a miss returns None — that is not an error. Only catch exceptions from transport failures:

handlers_cache.py
from pydantic import BaseModel
from imperal_sdk import ActionResult
import logging

log = logging.getLogger(__name__)


class InboxSummary(BaseModel):
    """Registered via @ext.cache_model("inbox_summary") in app.py."""
    items: list = []


class ListItemsParams(BaseModel):
    force_refresh: bool = False


async def fn_list_items(ctx, params: ListItemsParams) -> ActionResult:
    # Cache miss is expected and normal — not an error
    cached = None
    if not params.force_refresh:
        try:
            cached = await ctx.cache.get("inbox_summary", InboxSummary)
        except Exception as exc:
            # Cache transport error — degrade gracefully, fetch live
            log.warning("cache get failed, fetching live: %s", exc)

    if cached is None:
        try:
            resp = await ctx.http.get("/inbox")
        except Exception as exc:
            log.error("inbox fetch failed: %s", exc)
            return ActionResult.error(
                "Could not load inbox. Please try again.",
                retryable=True,
            )
        items = resp.json().get("items", [])
    else:
        items = cached.items

    return ActionResult.success(
        data={"items": items},
        summary=f"Loaded {len(items)} items",
    )

Cross-extension call failures

ctx.extensions.call(app_id, method, **kwargs) can raise several exceptions:

ExceptionCause
CircularCallErrorCall chain creates a cycle (A → B → A)
NotFoundErrorTarget extension or method does not exist
AuthErrorCaller lacks required scope
ExtensionErrorCall depth exceeds the platform limit (8 levels)
handlers_compose.py
from pydantic import BaseModel
from imperal_sdk import ActionResult, CircularCallError
from imperal_sdk.errors import NotFoundError, AuthError, ExtensionError
import logging

log = logging.getLogger(__name__)


class SendWithAttachmentParams(BaseModel):
    note_id: str
    recipient: str


async def fn_send_note_as_email(ctx, params: SendWithAttachmentParams) -> ActionResult:
    # Fetch note content via cross-extension call
    try:
        note = await ctx.extensions.call("notes", "get_note_content", note_id=params.note_id)
    except NotFoundError:
        return ActionResult.error(
            f"Note '{params.note_id}' not found. Cannot send."
        )
    except AuthError:
        return ActionResult.error(
            "This extension does not have permission to read notes."
        )
    except CircularCallError as exc:
        log.error("circular extension call: %s", exc)
        return ActionResult.error("Internal configuration error. Please contact support.")
    except ExtensionError as exc:
        log.error("extensions.call failed: %s", exc)
        return ActionResult.error("Could not load note content. Please try again.", retryable=True)

    # Continue with send logic...
    return ActionResult.success(
        data={"sent": True},
        summary=f"Note sent to {params.recipient}",
    )

Webhook failures

Webhook handlers receive (ctx, headers, body, query_params) and must return an ActionResult or raise. If your handler returns ActionResult.error(...), the platform responds with HTTP 200 but records the failure in the audit ledger. To signal HTTP-level failure to the caller (e.g., for webhook retry mechanisms), raise an exception — but do so only when you intend the caller to retry.

app.py
from pydantic import BaseModel
from imperal_sdk import Extension, ActionResult
import hmac, hashlib, logging

ext = Extension(
    "my-ext",
    display_name="My Extension",
    description="Example webhook handler with HMAC verification.",
    actions_explicit=True,
)

log = logging.getLogger(__name__)
WEBHOOK_SECRET = "loaded-from-env"


@ext.webhook("/events", secret_header="X-Webhook-Signature")
async def on_webhook(ctx, headers: dict, body: str, query_params: dict) -> ActionResult:
    # Verify HMAC signature — your responsibility; SDK provides no helper
    sig = headers.get("x-webhook-signature", "")
    expected = "sha256=" + hmac.new(
        WEBHOOK_SECRET.encode(), body.encode(), hashlib.sha256
    ).hexdigest()
    if not hmac.compare_digest(sig, expected):
        # Return ActionResult.error for invalid signature
        log.warning("webhook: invalid signature from %s", headers.get("host"))
        return ActionResult.error("Invalid webhook signature")

    try:
        payload = __import__("json").loads(body)
    except Exception:
        return ActionResult.error("Invalid JSON payload")

    event_type = payload.get("type", "unknown")
    log.info("webhook received event: %s", event_type)

    # Process event...
    return ActionResult.success(data={"event_type": event_type}, summary="Webhook processed")

HMAC verification is your responsibility

The SDK provides no built-in HMAC helper. You must verify signatures yourself using hmac.compare_digest — never string equality — to avoid timing attacks.


Scheduled job failures

@ext.schedule handlers run with a system context (ctx.user.imperal_id == "__system__"). They fan out to per-user contexts via ctx.store.list_users() + ctx.as_user(uid). Failures in per-user processing must be isolated — one user's failure must not abort the job for all other users.

handlers_schedule.py
from imperal_sdk import Extension, ActionResult
import logging

ext = Extension(
    "my-ext",
    display_name="My Extension",
    description="Example scheduled job with per-user isolation.",
    actions_explicit=True,
)

log = logging.getLogger(__name__)


@ext.schedule("daily_digest", cron="0 9 * * *")
async def run_daily_digest(ctx) -> None:
    """Send daily digest to all active users."""
    errors = []

    async for user_id in ctx.store.list_users("preferences"):
        user_ctx = ctx.as_user(user_id)
        try:
            await _send_digest_for_user(user_ctx)
        except Exception as exc:
            # Isolate — log the failure but continue to next user
            log.error("digest failed for user %s: %s", user_id, exc)
            errors.append(user_id)

    if errors:
        log.warning("digest completed with %d failures: %s", len(errors), errors[:5])
    else:
        log.info("digest completed successfully")


async def _send_digest_for_user(ctx) -> None:
    prefs = await ctx.store.get("preferences", "digest")
    if prefs is None or not prefs.data.get("enabled"):
        return
    # ... fetch and send
    await ctx.notify("Your daily digest is ready", priority="low")

Scheduled handlers do not return ActionResult — they return None. There is no user-facing error surface. All failure reporting goes through logging.


Event handler failures

@ext.on_event handlers are fire-and-forget: the platform dispatches events asynchronously and does not retry on failure. The context available in event handlers is minimal — ctx.http, ctx.store, and ctx.notify are typically available, but ctx.ai may not be, depending on the event routing path.

app.py
from imperal_sdk import Extension
import logging

ext = Extension(
    "my-ext",
    display_name="My Extension",
    description="Example event handler with graceful degradation.",
    actions_explicit=True,
)

log = logging.getLogger(__name__)


@ext.on_event("notes.created")
async def on_note_created(ctx, event) -> None:
    """React to a note creation event.

    Failures here are silent to the user — log everything.
    No retry will be issued.
    """
    try:
        note_id = event.data.get("note_id")
        if not note_id:
            log.warning("on_note_created: event missing note_id, skipping")
            return

        # Attempt secondary action — cache invalidation, indexing, etc.
        await ctx.store.update("indexes", "notes_count", {"invalidated": True})
    except Exception as exc:
        # Must not raise — that would silently drop the event
        log.error("on_note_created handler error: %s", exc)

Never raise from @ext.on_event

Unhandled exceptions in event handlers are swallowed by the platform and produce no user-visible feedback. Always catch broadly and log. Do not attempt rollback or compensating actions from event handlers — use @ext.on_install or write-path handlers for that.


Common pitfalls

Pitfall 1: leaking str(e) to the user

from imperal_sdk import ActionResult
import logging

log = logging.getLogger(__name__)


async def _bad_example(ctx, e: Exception) -> ActionResult:
    # ❌ Wrong — exposes internal exception details
    return ActionResult.error(str(e))


async def _good_example(ctx, e: Exception) -> ActionResult:
    # ✅ Correct — stable user-facing message
    log.error("operation failed: %s", e)
    return ActionResult.error("Operation failed. Please try again.", retryable=True)

The mail extension has several instances of ActionResult.error(str(e), retryable=True) in its handlers. This pattern is acceptable when e is an SDK exception type whose message is already user-friendly — but for third-party library exceptions or raw Exception, always wrap the message.

Pitfall 2: swallowing errors silently

from imperal_sdk import ActionResult
import logging

log = logging.getLogger(__name__)


async def _bad_update(ctx, item_id: str, data: dict) -> None:
    # ❌ Wrong — failure is invisible
    try:
        await ctx.store.update("items", item_id, data)
    except Exception:
        pass  # silently ignored


async def _good_update(ctx, item_id: str, data: dict) -> ActionResult:
    # ✅ Correct — log and surface
    try:
        await ctx.store.update("items", item_id, data)
    except Exception as exc:
        log.error("update failed for %s: %s", item_id, exc)
        return ActionResult.error("Update failed. Please try again.", retryable=True)
    return ActionResult.success(data={}, summary="Updated")

Pitfall 3: bare except

from imperal_sdk import ActionResult


async def _bad_bare_except(ctx) -> ActionResult:
    # ❌ Wrong — catches BaseException including KeyboardInterrupt, SystemExit
    try:
        pass  # your operation here
    except:  # noqa: E722
        return ActionResult.error("Failed")
    return ActionResult.success(data={}, summary="Done")


async def _good_except(ctx) -> ActionResult:
    # ✅ Correct — catch Exception only
    try:
        pass  # your operation here
    except Exception:
        return ActionResult.error("Failed")
    return ActionResult.success(data={}, summary="Done")

Pitfall 4: raising in @ext.on_event

from imperal_sdk import Extension
import logging

ext = Extension(
    "mail-ext",
    display_name="Mail",
    description="Example showing correct event handler error handling.",
    actions_explicit=True,
)
log = logging.getLogger(__name__)


async def _process_mail_stub(event: object) -> bool:
    return True  # placeholder


# ❌ Wrong — exception is swallowed, produces no feedback
@ext.on_event("mail.received.bad")
async def on_mail_bad(ctx, event) -> None:  # type: ignore[no-untyped-def]
    result = await _process_mail_stub(event)
    if not result:
        raise RuntimeError("processing failed")  # silently lost


# ✅ Correct — log and return
@ext.on_event("mail.received")
async def on_mail(ctx, event) -> None:  # type: ignore[no-untyped-def]
    try:
        result = await _process_mail_stub(event)
        if not result:
            log.warning("on_mail: processing returned empty result")
    except Exception as exc:
        log.error("on_mail handler error: %s", exc)

Pitfall 5: calling ctx.skeleton.get() outside @ext.skeleton

from pydantic import BaseModel
from imperal_sdk import Extension, ChatExtension, ActionResult

ext = Extension(
    "notes-ext",
    display_name="Notes",
    description="Example showing correct skeleton access patterns.",
    actions_explicit=True,
)
chat = ChatExtension(
    ext,
    tool_name="tool_notes_chat",
    description="AI chat interface for the notes extension",
)


class SearchParams(BaseModel):
    query: str


# ❌ Wrong — raises SkeletonAccessForbidden at runtime (do not run this)
# @chat.function("search_notes_bad", description="Search notes — wrong pattern",
#                action_type="read")
# async def fn_search_bad(ctx, params: SearchParams) -> ActionResult:
#     schema = await ctx.skeleton.get("notes")  # SkeletonAccessForbidden!
#     ...


# ✅ Correct — read live data from ctx.store or ctx.http
@chat.function(
    "search_notes",
    description="Search notes by title using live store data",
    action_type="read",
)
async def fn_search(ctx, params: SearchParams) -> ActionResult:
    # Use ctx.store for live data; skeleton is for the LLM classifier only
    results = await ctx.store.query("notes", where={"title": params.query})
    return ActionResult.success(
        data={"results": [d.data for d in results.data]},
        summary=f"Found {len(results.data)} notes",
    )

Pitfall 6: returning error from @ext.skeleton

Skeleton handlers should degrade to empty/zero values on failure, not return error shapes. The web-kernel feeds skeleton data to the classifier — an unexpected shape breaks the classification step.

from imperal_sdk import Extension, ActionResult
import logging

ext = Extension(
    "notes-ext",
    display_name="Notes",
    description="Example showing correct skeleton failure handling.",
    actions_explicit=True,
)
log = logging.getLogger(__name__)


# ❌ Wrong — skeleton returning ActionResult.error shape
@ext.skeleton("notes_bad", ttl=300)
async def skeleton_refresh_notes_bad(ctx) -> dict:
    try:
        pass  # fetch data
    except Exception:
        return ActionResult.error("failed").to_dict()  # wrong shape!
    return {"response": {}}


# ✅ Correct — return safe zero values
@ext.skeleton("notes", ttl=300)
async def skeleton_refresh_notes(ctx) -> dict:
    count = 0
    try:
        count = await ctx.store.count("notes")
        return {"response": {"total": count}}
    except Exception as exc:
        log.error("skeleton refresh failed: %s", exc)
        return {"response": {"total": 0, "recent": []}}

See also

On this page