@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_casethat 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 validateoutput — make it recognisable. - Names starting with
skeleton_refresh_should use@ext.skeletoninstead. Validator ruleMANIFEST-SKELETON-1flags direct@ext.tool("skeleton_refresh_*")registrations as errors. - Internal synthetic tools registered by
@ext.webhook,@ext.panel,@ext.tray, and@ext.widgetuse 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:
- The SDK creates a
ToolDef(name=name, func=func, scopes=scopes or [], description=description or func.__doc__ or ""). - This
ToolDefis stored inext._tools[name]. - The decorated function is returned unchanged — no wrapper is added.
- At manifest build time, the tool appears in the
tools[]array withname,description,scopes, and auto-derivedparametersfrom 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(...)orActionResult.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.skeletonhandlers.
@ext.tool vs @chat.function
| Aspect | @ext.tool | @chat.function |
|---|---|---|
| LLM-visible | No — web-kernel dispatches only | Yes — LLM selects via tool_use |
| JSON schema in manifest | Auto-derived but not surfaced to LLM | Required (V16-V17); shown to classifier |
| KAV confirmation gate | Not triggered | Triggered for write/destructive |
action_type kwarg | Not present | Required semantics: read/write/destructive |
chain_callable | Not present | Controls typed chain dispatch |
effects | Not present | Declares panel refresh targets |
| Primary use case | Alert callbacks, internal helpers, legacy skeleton tools | User-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.user—UserContextwithimperal_id,id,email,role,attributesctx.tenant—TenantContext | Nonectx.store—StoreClientfor persistent per-user datactx.http—HTTPProtocolfor outbound HTTP callsctx.cache—CacheClientfor short-lived Pydantic snapshots (5–300s TTL)ctx.ai—AIProtocolfor LLM completionsctx.notify— send in-app notificationsctx.config— read admin-configured extension settingsctx.billing— check limits before expensive operationsctx.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"]