Imperal Docs
Getting Started

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.function handlers (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 saysTool firesReturns
"add a task: review the budget on Friday"create_taskthe new task
"what's on my list?"list_tasksarray 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-sdk

Create the file layout:

🐍app.py
🐍panels.py
🐍state.py
📋imperal.json

Three Python modules + the auto-generated manifest.

Define the data model and storage

state.py
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

app.py
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 a ValidationError, 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 before clear_done_tasks actually 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.

panels.py
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:

app.py (add to top)
from .panels import tasks_sidebar  # noqa: F401  — registers the panel

Build the manifest

imperal build

This 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 test

Loads 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:

tests/test_handlers.py
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"] == 1

Publish 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

On this page