Imperal Docs
SDK Reference

@ext.tool reference

Generic tool registration — internal helpers, skeleton alert handlers, and cross-extension callable targets

@ext.tool is the foundational decorator that registers any callable as a named tool on an Extension instance. Every higher-level decorator — @ext.skeleton, @ext.webhook, @ext.panel — internally calls @ext.tool under the hood. Use @ext.tool directly when none of the specialised decorators fit: skeleton alert handlers, internal computation helpers, and low-level backend tools that the web-kernel dispatches directly but that should not appear as LLM-visible chat functions.

The key distinction from @chat.function: @ext.tool does not produce an LLM tool-use schema. The web-kernel can invoke it directly (e.g. for skeleton alert callbacks or chain dispatch), but the intent classifier cannot select it by name in response to a user message. Use @chat.function when you need the LLM to be able to call a function autonomously.

Where it lives

@ext.tool is a method on the Extension instance (not on ChatExtension). Register tools from the same file that holds your ext = Extension(...), or import ext from app.py into dedicated handler modules — the pattern used by every production extension.

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.tool("my_helper", scopes=["my-app.read"], description="Read current resource status.")
async def my_helper(ctx, resource_id: str = "") -> dict:
    item = await ctx.store.get("resources", resource_id)
    return {"response": item.data if item else {}}

Signature

def tool(
    self,
    name: str,
    scopes: list[str] | None = None,
    description: str = "",
) -> Callable:
    ...

All three parameters are positional. There are no keyword-only arguments.


Kwargs reference

name

Required. Positional. The tool identifier. Used as the dispatch key in the web-kernel and in the extension manifest.

Prop

Type

Rules:

  • Use snake_case that describes what the tool does — e.g. "skeleton_alert_tasks", "db_query_execute", "connection_status_check".
  • The name is used verbatim in web-kernel dispatch logs and in imperal validate output — make it recognisable.
  • Names starting with skeleton_refresh_ should use @ext.skeleton instead. Validator rule MANIFEST-SKELETON-1 flags direct @ext.tool("skeleton_refresh_*") registrations as errors.
  • Internal synthetic tools registered by @ext.webhook, @ext.panel, @ext.tray, and @ext.widget use reserved prefixes (__webhook__, __panel__, __tray__, __widget__). Do not register tools with these prefixes manually.

scopes

Prop

Type

Scopes are checked by the web-kernel before dispatching the tool. If the user does not hold the required scopes, the call is rejected before the handler runs.

Common scope conventions from production extensions:

  • ["tasks.read"] — read-only access to task data
  • ["sql-db.read"] — read-only database access
  • ["sql-db.write"] — mutation capability (DDL/DML)
  • ["*"] — full access; use only for system-level tools with no user-data sensitivity

Skeleton alert tools (skeleton_alert_{section}) use explicit scopes matching the data they inspect:

from imperal_sdk import Extension

ext = Extension(
    "tasks",
    display_name="Tasks",
    description="Tasks extension — manage and track your tasks with AI assistance.",
    icon="icon.svg",
    actions_explicit=True,
)

@ext.tool(
    "skeleton_alert_tasks",
    scopes=["tasks.read"],
    description="Alert on new overdue tasks or sudden backlog spikes.",
)
async def skeleton_alert_tasks(
    ctx,
    old: dict | None = None,
    new: dict | None = None,
) -> dict:
    if not new:
        return {"response": ""}
    overdue     = new.get("overdue_count", 0)
    old_overdue = (old or {}).get("overdue_count", 0)
    if overdue > 0 and overdue > old_overdue:
        delta = overdue - old_overdue
        return {"response": f"{delta} new overdue task(s) — {overdue} total"}
    return {"response": ""}

description

Prop

Type

Always supply a description. If omitted, the SDK falls back to func.__doc__. If the function has no docstring either, the manifest emits an empty string — which fails V16 for @chat.function tools but is only a warning for plain @ext.tool.


Internal storage

When you apply @ext.tool(name, ...) to a function:

  1. The SDK creates a ToolDef(name=name, func=func, scopes=scopes or [], description=description or func.__doc__ or "").
  2. This ToolDef is stored in ext._tools[name].
  3. The decorated function is returned unchanged — no wrapper is added.
  4. At manifest build time, the tool appears in the tools[] array with name, description, scopes, and auto-derived parameters from the function signature.

Return contract

@ext.tool imposes no return-type contract beyond what the web-kernel expects for the specific dispatch context:

  • Skeleton alert handlers — return {"response": "<alert string>"}. An empty string means no alert; a non-empty string is surfaced as ambient classifier context.
  • General tools — return ActionResult.success(...) or ActionResult.error(...) if the tool is invoked in a chat context. Return a raw dict if invoked only by web-kernel-internal workflows.
  • Skeleton refresh tools registered via @ext.tool (legacy pattern) — must return {"response": <dict>} following the same contract as @ext.skeleton handlers.

@ext.tool vs @chat.function

Aspect@ext.tool@chat.function
LLM-visibleNo — web-kernel dispatches onlyYes — LLM selects via tool_use
JSON schema in manifestAuto-derived but not surfaced to LLMRequired (V16-V17); shown to classifier
KAV confirmation gateNot triggeredTriggered for write/destructive
action_type kwargNot presentRequired semantics: read/write/destructive
chain_callableNot presentControls typed chain dispatch
effectsNot presentDeclares panel refresh targets
Primary use caseAlert callbacks, internal helpers, legacy skeleton toolsUser-requested reads, writes, AI-driven actions

@ext.skeleton is sugar over @ext.tool. It registers your handler under the name skeleton_refresh_{section_name} and attaches skeleton metadata. Prefer @ext.skeleton for all skeleton refresh handlers — use @ext.tool only for the paired alert handler.


ctx in @ext.tool handlers

Tool handlers receive the full Context object, identical to @chat.function and @ext.skeleton handlers:

  • ctx.userUserContext with imperal_id, id, email, role, attributes
  • ctx.tenantTenantContext | None
  • ctx.storeStoreClient for persistent per-user data
  • ctx.httpHTTPProtocol for outbound HTTP calls
  • ctx.cacheCacheClient for short-lived Pydantic snapshots (5–300s TTL)
  • ctx.aiAIProtocol for LLM completions
  • ctx.notify — send in-app notifications
  • ctx.config — read admin-configured extension settings
  • ctx.billing — check limits before expensive operations
  • ctx.time — web-kernel-injected time context (timezone, now_local, is_business_hours)

ctx.skeleton is guarded

ctx.skeleton.get() raises SkeletonAccessForbidden if called from a plain @ext.tool handler that is not a skeleton-registered function. Validator rule V24 (AST scan) flags this at validate time. Read current skeleton data from ctx.cache or ctx.store in tool handlers.


Production examples

Skeleton alert handler — tasks

From tasks/skeleton.py. This is the canonical @ext.tool use case: a paired alert handler that compares old and new skeleton snapshots and returns a human-readable alert string.

from imperal_sdk import Extension

ext = Extension(
    "tasks",
    display_name="Tasks",
    description="Tasks extension — manage and track your tasks with AI assistance.",
    icon="icon.svg",
    actions_explicit=True,
)


@ext.skeleton("tasks", alert=True, ttl=30)
async def skeleton_refresh_tasks(ctx) -> dict:
    overdue = await ctx.store.count("tasks", where={"overdue": True})
    today   = await ctx.store.count("tasks", where={"due_today": True})
    return {"response": {"overdue_count": overdue, "today_count": today}}


@ext.tool(
    "skeleton_alert_tasks",
    scopes=["tasks.read"],
    description="Alerts: overdue tasks, due-today, sudden spike in backlog.",
)
async def skeleton_alert_tasks(
    ctx,
    old: dict | None = None,
    new: dict | None = None,
) -> dict:
    """Compare old/new skeleton, alert on overdue spikes and due-today changes."""
    if not new:
        return {"response": ""}
    overdue     = new.get("overdue_count", 0)
    old_overdue = (old or {}).get("overdue_count", 0)
    if overdue > 0 and overdue > old_overdue:
        delta = overdue - old_overdue
        label = f"skeleton_alert_tasks: {delta} new overdue task(s) (now {overdue} total)"
        return {"response": label}
    return {"response": ""}

Schema change alert handler — sql-db

From sql-db/skeleton.py. Detects table additions and removals by diffing the previous and new schema snapshots.

from imperal_sdk import Extension

ext = Extension(
    "sql-db",
    display_name="SQL Database",
    description="SQL Database extension — run queries and explore schemas with AI.",
    icon="icon.svg",
    actions_explicit=True,
)


@ext.skeleton("db_schema", alert=True, ttl=300)
async def skeleton_refresh_db_schema(ctx) -> dict:
    return {"response": {"table_count": 0, "tables": []}}


@ext.tool(
    "skeleton_alert_db_schema",
    scopes=["sql-db.read"],
    description="Alert on schema changes: tables added or removed.",
)
async def skeleton_alert_db_schema(
    ctx,
    old: dict | None = None,
    new: dict | None = None,
    **kwargs,
) -> dict:
    """Compare old and new schema, alert on table additions/removals."""
    if not old or not new:
        return {"response": ""}
    old_tables = {t["name"] for t in old.get("tables", [])}
    new_tables  = {t["name"] for t in new.get("tables", [])}
    added   = new_tables - old_tables
    removed = old_tables - new_tables
    if not added and not removed:
        return {"response": ""}
    parts = []
    if added:
        parts.append(f"New tables: {', '.join(sorted(added))}")
    if removed:
        parts.append(f"Removed tables: {', '.join(sorted(removed))}")
    return {"response": f"Schema changed — {'; '.join(parts)}"}

Low-level query helper

A standalone backend tool — not LLM-visible, not a skeleton alert, just a named callable the web-kernel can dispatch directly via the extension's call_tool method.

from imperal_sdk import Extension
from imperal_sdk.chat import ActionResult

ext = Extension(
    "sql-db",
    display_name="SQL Database",
    description="SQL Database extension — run queries and explore schemas with AI.",
    icon="icon.svg",
    actions_explicit=True,
)


@ext.tool(
    "ping_connection",
    scopes=["sql-db.read"],
    description="Ping the active database connection. Returns latency_ms and connected flag.",
)
async def ping_connection(ctx) -> ActionResult:
    try:
        resp = await ctx.http.get(
            "http://db-service/v1/ping",
            params={"user_id": ctx.user.imperal_id},
        )
        data = resp.json()
        return ActionResult.success(
            data={"connected": data.get("ok", False), "latency_ms": data.get("latency_ms", 0)},
            summary="Connection ping succeeded.",
        )
    except Exception as exc:
        return ActionResult.error(f"Ping failed: {exc}")

Common pitfalls

Using @ext.tool for skeleton refresh handlers

from imperal_sdk import Extension

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


# WRONG — flagged by validator MANIFEST-SKELETON-1; use @ext.skeleton instead
@ext.tool("skeleton_refresh_tasks")
async def skeleton_refresh_tasks_wrong(ctx) -> dict:
    return {"response": {"total": 0}}


# CORRECT — use @ext.skeleton for refresh handlers
@ext.skeleton("tasks", ttl=60)
async def skeleton_refresh_tasks(ctx) -> dict:
    total = await ctx.store.count("tasks")
    return {"response": {"total": total}}

Expecting LLM selection

@ext.tool handlers are not visible to the intent classifier. Do not expect the LLM to call a plain tool by name in response to a user message:

from imperal_sdk import Extension, ChatExtension
from imperal_sdk.chat import ActionResult

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


# WRONG — user says "show me resources", LLM cannot select this
@ext.tool("list_resources", description="List resources.")
async def list_resources_wrong(ctx) -> dict:
    return {"response": []}


# CORRECT — expose user-facing actions via @chat.function
@chat.function(
    name="list_resources",
    description="List all resources in the user's workspace.",
    action_type="read",
)
async def list_resources(ctx) -> ActionResult:
    items = await ctx.store.query("resources")
    return ActionResult.success(
        data={"resources": [i.data for i in items.data]},
        summary="Listed all resources.",
    )

Duplicate tool names

Each @ext.tool name must be unique within an extension. Registering the same name twice silently overwrites the first registration — the SDK does not raise an error:

from imperal_sdk import Extension

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


# WRONG — second registration silently replaces first
@ext.tool("my_check", description="First version.")
async def check_v1(ctx) -> dict:
    return {"response": "v1"}


@ext.tool("my_check", description="Second version.")  # overwrites check_v1
async def check_v2(ctx) -> dict:
    return {"response": "v2"}

Testing

Import the handler function directly from your module and call it with a MockContext. The @ext.tool decorator does not wrap the function, so the original callable is available directly.

import pytest
from imperal_sdk.testing import MockContext, MockStore

# Assume skeleton_alert_tasks is importable from your module
# from skeleton import skeleton_alert_tasks

@pytest.mark.asyncio
async def test_alert_fires_on_new_overdue():
    ctx = MockContext(user_id="test_user")
    ctx.store = MockStore()

    old = {"overdue_count": 1, "today_count": 0}
    new = {"overdue_count": 3, "today_count": 0}

    # result = await skeleton_alert_tasks(ctx, old=old, new=new)
    # assert "2 new overdue" in result["response"]

Cross-references

On this page