Imperal Docs
Guides

Building extensions

A complete walkthrough — from idea to published, federal-clean

This is the long-form guide to building production extensions. If you finished Quick Start and Your First Extension, this page deepens those skills with real-world patterns.

What you'll build

A docs-mate extension that:

  1. Lets users save snippets ("save this as a snippet for later")
  2. Lists snippets ("show me my snippets")
  3. Searches by content ("find the AWS snippet")
  4. Has a sidebar showing recent snippets
  5. Has a skeleton tracking the count

It's about 200 lines of Python, end to end.

Project shape

🐍app.py
🐍handlers_chat.py
🐍handlers_panel.py
🐍handlers_skeleton.py
🐍schemas.py
📦requirements.txt

Set up the venv

mkdir docs-mate && cd docs-mate
python3.12 -m venv .venv && source .venv/bin/activate
pip install imperal-sdk

Define the data model (schemas.py)

All Pydantic models at module scope — V17 requires this.

schemas.py
from pydantic import BaseModel, Field

class SaveSnippetParams(BaseModel):
    title: str = Field(description="Short title for the snippet (1 sentence max)")
    content: str = Field(description="The snippet body — code, text, anything")
    tags: list[str] = Field(default_factory=list, description="Optional tags")

class ListSnippetsParams(BaseModel):
    limit: int = Field(20, description="Max snippets to return (1-100)")

class SearchSnippetsParams(BaseModel):
    query: str = Field(description="Search term — matched against title and content")
    limit: int = Field(10, description="Max results to return")

class DeleteSnippetParams(BaseModel):
    snippet_id: str = Field(description="UUID of the snippet to delete — from a previous list or save call")

Write the Extension + ChatExtension (app.py)

app.py
from imperal_sdk import Extension, ChatExtension

ext = Extension(
    "docs-mate",
    version="1.0.0",
    display_name="Docs Mate",
    description="A snippet manager Webbee can drive in plain language. Save, search, and manage code snippets and notes.",
    icon="icon.svg",
    actions_explicit=True,
)

chat = ChatExtension(
    ext,
    tool_name="docs-mate",
    description="Docs Mate assistant — saves, lists, searches, and deletes snippets.",
    system_prompt="You help users manage their saved snippets. Be concise.",
)

# Side-effect imports: register decorators
from . import handlers_chat     # noqa: F401
from . import handlers_panel    # noqa: F401
from . import handlers_skeleton # noqa: F401

Your manifest description IS your capabilities answer

When a user asks Webbee "what can you do?", the platform paraphrases the display_name + description of every installed extension verbatim — there is no separate "capabilities" string. Federal I-CAPABILITY-LIST-MUST-BE-GROUNDED enforces this: the LLM is forbidden from inventing capabilities beyond what your description literally says.

The implication: write descriptions that name the actual user-facing capability. Don't write "Notes extension that wraps the notes API" (the LLM can't translate that into anything useful for users). Write "Create, list, search, archive, and delete personal notes. Notes can be tagged and grouped by folder." Concrete verbs + concrete nouns. That's the answer your future users will read in chat.

Write the chat functions (handlers_chat.py)

handlers_chat.py
from imperal_sdk import ActionResult
from .app import chat
from .schemas import (
    SaveSnippetParams, ListSnippetsParams,
    SearchSnippetsParams, DeleteSnippetParams,
)

@chat.function(
    description="Save a snippet — text, code, anything the user wants to keep for later.",
    action_type="write",
    chain_callable=True,
    effects=["snippet.create"],
    event="snippet.created",
)
async def save_snippet(ctx, params: SaveSnippetParams):
    doc = await ctx.store.create("snippets", {
        "title": params.title,
        "content": params.content,
        "tags": params.tags,
    })
    return ActionResult.success(
        {"snippet_id": doc.id, "title": params.title},
        summary=f"Saved snippet: {params.title}",
        refresh_panels=["snippets-sidebar"],
    )

@chat.function(
    description="List the user's saved snippets, newest first.",
    action_type="read",
)
async def list_snippets(ctx, params: ListSnippetsParams):
    page = await ctx.store.query(
        "snippets", order_by="-created_at", limit=params.limit
    )
    return ActionResult.success(
        {"snippets": [{"id": d.id, **d.data} for d in page.data]},
        summary=f"You have {len(page.data)} snippet(s).",
    )

@chat.function(
    description="Search saved snippets by content or title.",
    action_type="read",
)
async def search_snippets(ctx, params: SearchSnippetsParams):
    # ctx.store.query with flat where for simple matching
    page = await ctx.store.query("snippets", limit=100)
    q = params.query.lower()
    matches = [
        {"id": d.id, **d.data}
        for d in page.data
        if q in d.data.get("title", "").lower()
        or q in d.data.get("content", "").lower()
    ][:params.limit]
    return ActionResult.success(
        {"snippets": matches},
        summary=f"Found {len(matches)} match(es) for '{params.query}'.",
    )

@chat.function(
    description="Permanently delete a snippet by its ID. This cannot be undone.",
    action_type="destructive",
    chain_callable=True,
    effects=["snippet.delete"],
    id_projection="snippet_id",
)
async def delete_snippet(ctx, params: DeleteSnippetParams):
    ok = await ctx.store.delete("snippets", params.snippet_id)
    if not ok:
        return ActionResult.error("Snippet not found.", retryable=False)
    return ActionResult.success(
        {"deleted": params.snippet_id},
        summary="Snippet deleted.",
        refresh_panels=["snippets-sidebar"],
    )

Always return [ActionResult](/en/reference/glossary/)

Never return bare dicts from @chat.function handlers. ActionResult.success() and ActionResult.error() are the only valid return types. The web-kernel's Magic UX layer (I-MAGIC-UX-1/2) formats errors — never pass str(e).

Add a sidebar panel (handlers_panel.py)

handlers_panel.py
from imperal_sdk import ui
from .app import ext

@ext.panel(
    "snippets-sidebar",
    slot="left",
    title="Snippets",
    icon="🔖",
    refresh="on_event:docs-mate.snippet.created,docs-mate.snippet.deleted",
    default_width=260,
    min_width=200,
    max_width=400,
)
async def snippets_sidebar(ctx, **params):
    page = await ctx.store.query("snippets", order_by="-created_at", limit=5)
    if not page.data:
        return ui.Empty(message="Say 'save this snippet…' in chat to start.")
    return ui.Stack(
        direction="v",
        children=[
            ui.Text(f"{len(page.data)} recent snippet(s)", variant="caption"),
            ui.List(items=[
                ui.ListItem(
                    label=d.data["title"],
                    subtitle=", ".join(d.data.get("tags", [])) or "no tags",
                    actions=[
                        ui.Call(
                            label="Delete",
                            tool="docs-mate.delete_snippet",
                            args={"snippet_id": d.id},
                        )
                    ],
                )
                for d in page.data
            ]),
        ],
    )

Add a skeleton (handlers_skeleton.py)

handlers_skeleton.py
from .app import ext

@ext.skeleton("snippets_summary", alert=True, ttl=120)
async def refresh_snippets_skeleton(ctx) -> dict:
    total = await ctx.store.count("snippets")
    return {"response": {
        "total_snippets": total,
    }}

Now Webbee always knows how many snippets you have, even if you don't ask. When the user says "do I have a snippet about AWS?", Webbee reads total_snippets from the skeleton before deciding whether to call search_snippets.

Return contract: {"response": {...}} with flat scalar fields. The intent classifier reads them directly from the user's envelope.

Emit events for panel refresh

The sidebar's refresh="on_event:docs-mate.snippet.created,..." pattern requires declaring the events:

app.py (additions)
@ext.emits("docs-mate.snippet.created")
async def _declare_snippet_created(): ...

@ext.emits("docs-mate.snippet.deleted")
async def _declare_snippet_deleted(): ...

The ActionResult.success(..., refresh_panels=["snippets-sidebar"]) in your handlers will trigger the panel fetch automatically without needing SSE events for simple cases. The on_event: pattern is for cross-handler SSE-driven refresh.

Build and validate

# Generate manifest
imperal build .

# Run all federal validators (V14-V22+V24+V31)
imperal validate .

# Syntax check every file before deploy
python3 -m py_compile app.py handlers_chat.py handlers_panel.py handlers_skeleton.py schemas.py

A clean run looks like:

✅ V14: manifest_schema_version=3 — OK
✅ V15: display_name="Docs Mate" (12 chars) — OK
✅ V16: All tool descriptions ≥10 chars — OK
✅ V17: All Pydantic models at module scope — OK
✅ V18: All @chat.function return ActionResult — OK
✅ V19: actions_explicit=True — OK
✅ V20: action_type valid for all tools — OK
✅ V21: icon.svg valid SVG — OK
✅ V22: No print() in handlers — OK
✅ V24: No ctx.skeleton.* in @chat.function — OK

Deploy via Developer Portal

git add -A && git commit -m "docs-mate: initial release"
git push origin main

Then: Developer Portal → Deploy tab → paste your git URL → Deploy.

No direct file deploy

Deploy only through Developer Portal git deploy. Never copy files directly to the server — changes must go through git so the web-kernel and manifest stay in sync.

Patterns you'll repeat

Common mistakes

Wrong ChatExtension wiring. from imperal_sdk import ChatExtension, ActionResult and then @chat.function is incorrect — chat is not a global singleton. Create a ChatExtension instance: chat = ChatExtension(ext, tool_name="my-app", description="...") and use @chat.function on that instance.

@panels.sidebar doesn't exist. Use @ext.panel("panel-id", slot="left"). There is no panels module with .sidebar, .editor, .settings, or .action decorators.

Wrong @ext.skeleton signature. The first argument is a section name string: @ext.skeleton("section_name", ttl=300) — not @ext.skeleton(refresh_seconds=120). The refresh_seconds parameter does not exist.

Blind r.json() on HTTP responses. When you call an external service (auth-gw, third-party API) from a handler, always check r.status_code before parsing JSON. A 4xx/5xx with an HTML error page or an empty body will explode r.json() with JSONDecodeError: Expecting value: line 1 column 1 (char 0) — the user sees an opaque "internal error" message and you've lost the real upstream reason.

Defensive HTTP wrapper
async def _gw_request(method, path, data=None):
    r = await ctx.http.request(method, path, json=data)
    if r.status_code >= 400:
        body = (r.text or "").strip()
        try:
            payload = r.json()
            detail = payload.get("detail") or payload.get("error") or body[:300]
        except Exception:
            detail = body[:300] or "(empty body)"
        return {"error": f"HTTP {r.status_code}: {detail}"}
    if not r.content:
        return {}
    return r.json()

The caller then checks if "error" in result: return ActionResult.error(result["error"]). The user sees the actual upstream failure, not a cryptic JSONDecodeError.

What's next

On this page