Your First Real Extension
Build a typed extension with multiple handlers, panels, and audit — end to end
By the end of this page, you'll have a tasks-mini extension with:
- Two
@chat.functionhandlers (create + list) - Pydantic-typed parameters with automatic LLM retry on validation failure
- A small panel that renders a sidebar widget
- Federal audit chokepoint integration (free, automatic)
- Local smoke tests
- A publishable manifest
It's a real, useful extension. You can adapt the same shape for almost anything.
Prerequisites
Did you finish the Quick Start? If yes, you're ready. This page builds on the same patterns but adds state, validation, panels, and multiple tools.
Plan the extension
Before code: a tasks-mini extension that lets users:
| User says | Tool fires | Returns |
|---|---|---|
| "add a task: review the budget on Friday" | create_task | the new task |
| "what's on my list?" | list_tasks | array of pending tasks |
| "clear my done tasks" | clear_done (destructive — needs confirmation) | count cleared |
Three handlers. One panel showing the active list. State stored in a simple in-memory dict (in production: SQLite or the user's storage of choice).
Scaffold the package
mkdir tasks-mini && cd tasks-mini
python3.12 -m venv .venv && source .venv/bin/activate
pip install imperal-sdkCreate the file layout:
Three Python modules + the auto-generated manifest.
Define the data model and storage
from dataclasses import dataclass, field
from datetime import datetime
from uuid import uuid4
@dataclass
class Task:
id: str = field(default_factory=lambda: str(uuid4()))
title: str = ""
due_at: str | None = None
done: bool = False
created_at: str = field(default_factory=lambda: datetime.utcnow().isoformat())
# In-memory store keyed by user_id (multi-tenant safety baked in)
_TASKS: dict[str, list[Task]] = {}
def get_tasks(user_id: str) -> list[Task]:
return _TASKS.setdefault(user_id, [])
def add_task(user_id: str, task: Task) -> Task:
get_tasks(user_id).append(task)
return task
def clear_done(user_id: str) -> int:
tasks = get_tasks(user_id)
before = len(tasks)
_TASKS[user_id] = [t for t in tasks if not t.done]
return before - len(_TASKS[user_id])Multi-tenant by default
Notice _TASKS is keyed by user_id. You must always scope state by user. A bug here is a federal incident. The web-kernel passes ctx.user.imperal_id for free — use it.
Write the chat functions
from imperal_sdk import Extension, ChatExtension, ActionResult
from pydantic import BaseModel, Field
from .state import Task, get_tasks, add_task, clear_done
# ─────────── parameter models ───────────
class CreateTaskParams(BaseModel):
title: str = Field(description="The task — what needs to be done")
due_at: str | None = Field(
None,
description="Optional ISO datetime when the task is due (e.g. '2026-06-15T09:00:00')",
)
class ListTasksParams(BaseModel):
only_pending: bool = Field(True, description="Hide completed tasks if True")
# ─────────── extension definition ───────────
ext = Extension(
"tasks-mini",
version="1.0.0",
display_name="Tasks (Mini)",
description="A tiny task manager Webbee can drive in plain language to add, list, and clear tasks.",
icon="icon.svg",
actions_explicit=True,
capabilities=["tasks:read", "tasks:write", "tasks:delete"],
)
chat = ChatExtension(
ext,
tool_name="tasks_mini",
description="Tasks (Mini) — a small to-do list managed via chat.",
)
# ─────────── handlers ───────────
@chat.function(
"create_task",
action_type="write",
event="task_created",
effects=["create:task"],
description="Add a task to the user's pending list.",
)
async def create_task(ctx, params: CreateTaskParams) -> ActionResult:
task = Task(title=params.title, due_at=params.due_at)
add_task(ctx.user.imperal_id, task)
return ActionResult.success(
data={"task_id": task.id, "title": task.title},
summary=f"Added: {task.title}",
)
@chat.function(
"list_tasks",
action_type="read",
description="List the user's tasks (optionally only the pending ones).",
)
async def list_tasks(ctx, params: ListTasksParams) -> ActionResult:
tasks = get_tasks(ctx.user.imperal_id)
if params.only_pending:
tasks = [t for t in tasks if not t.done]
return ActionResult.success(
data={
"tasks": [
{"id": t.id, "title": t.title, "due_at": t.due_at, "done": t.done}
for t in tasks
],
"count": len(tasks),
},
summary=f"{len(tasks)} task(s)",
)
class ClearDoneParams(BaseModel):
confirm: bool = Field(False, description="Set true to actually clear (safety gate)")
@chat.function(
"clear_done_tasks",
action_type="destructive",
event="tasks_cleared",
effects=["delete:task"],
description="Permanently remove all completed tasks. Destructive — confirmation required.",
)
async def clear_done_tasks(ctx, params: ClearDoneParams) -> ActionResult:
count = clear_done(ctx.user.imperal_id)
return ActionResult.success(
data={"cleared": count},
summary=f"Cleared {count} completed task(s).",
)What you get for free
- Pydantic validation: if the LLM sends
{"title": ""}, the SDK gets aValidationError, formats a message, and asks the LLM to retry. Up to 2 retries with structured prose feedback. - Audit chokepoint: every successful call is recorded with
(user_id, tenant_id, tool_name, action_type, status)to the federal action ledger. You write nothing. - Action confirmation:
action_type="destructive"triggers a "Are you sure?" card beforeclear_done_tasksactually runs.
Add a panel (optional UI surface)
A panel is a small UI block that renders inside the Imperal Panel app. Here we'll add a sidebar showing the user's pending count.
from imperal_sdk import ui
from .app import ext
from .state import get_tasks
@ext.panel("sidebar", slot="left", title="My Tasks", icon="📋")
async def tasks_sidebar(ctx, **kwargs):
tasks = get_tasks(ctx.user.imperal_id)
pending = [t for t in tasks if not t.done]
return ui.Stack(children=[
ui.Header(f"{len(pending)} pending", level=3),
ui.List(items=[
ui.ListItem(id=t.id, title=t.title, subtitle=t.due_at or "")
for t in pending[:10]
]),
], gap=2)Then wire it from app.py:
from .panels import tasks_sidebar # noqa: F401 — registers the panelBuild the manifest
imperal buildThis produces imperal.json with all three tools, parameter schemas, and the panel slot. The build runs all 11 validators (V14–V22 + V24 + V31) and fails loudly if anything is off.
Smoke test locally
imperal testLoads app.py, runs each registered handler against MockContext, and reports pass/fail. For deeper test coverage write pytest cases in a tests/ directory using the same MockContext import:
import pytest
from imperal_sdk.testing import MockContext
from app import create_task, list_tasks, CreateTaskParams, ListTasksParams
@pytest.mark.asyncio
async def test_create_then_list():
ctx = MockContext(user_id="imp_u_test")
res = await create_task(ctx, CreateTaskParams(title="Buy milk"))
assert res.status == "success"
listed = await list_tasks(ctx, ListTasksParams(only_pending=True))
assert listed.data["count"] == 1Publish through the Developer Portal
Open panel.imperal.io/developer and drag-and-drop your packaged extension. The portal re-runs every validator server-side and queues your submission for review. After review, install on your own user, open chat, and try:
"add a task to review the q3 numbers next thursday"
"what's pending?"
"clear my done" ← confirmation card pops up first
What you learned
Typed handlers
Pydantic models give you validation and a JSON schema for the LLM in one shot.
Multi-tenant state
Always key by ctx.user.[imperal_id](/en/reference/glossary/). The web-kernel passes the right user, fail-closed.
Action types
`action_type='destructive'` triggers automatic confirmation. Read-only tools skip it.
Panels
A panel is a Python function returning a JSON layout. Imperal Panel renders it.
Audit for free
Every call hits the federal ledger. You don't write a line of audit code.
Next steps
Architecture deep-dive
Now you understand handlers — see how the web-kernel orchestrates everything.
The Federal Contract
Read what every published extension MUST satisfy. The shape, the validators, the runtime invariants.
Real-world recipes
Send mail, query a database, do a multi-step chain, [BYOLLM](/en/reference/glossary/).