Imperal Docs
SDK Reference

@chat.function reference

@chat.function reference — every parameter of the decorator that defines an LLM-callable Webbee tool: action_type, typed params, effects, and typed dispatch.

@chat.function is the most-used decorator in the SDK. It registers a handler that the LLM can call by name with typed parameters. Every registered function appears as a tool in the LLM's tool-use schema, gates through action authorization, and writes an audit row on completion.

Where it lives

@chat.function is a method on a ChatExtension instance — not a global decorator and not a method on Extension directly. The pattern used in every production extension is:

from imperal_sdk import Extension
from imperal_sdk.chat import ChatExtension, ActionResult
from pydantic import BaseModel, Field

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

chat = ChatExtension(
    ext=ext,
    tool_name="my-app",
    description="My App assistant — ask anything about your resources.",
)

chat is then available at module scope. Handler files import it from app.py:

# handlers_resources.py
from app import chat, ActionResult

Functions are registered in handler files by decorating async functions:

@chat.function(
    "function_name",
    description="What this function does for the user.",
    action_type="read",
)
async def fn_function_name(ctx, params: MyParams) -> ActionResult:
    ...

Signature

def function(
    self,
    name: str,
    description: str,
    params: dict | None = None,
    action_type: str = "read",
    event: str = "",
    chain_callable: bool | None = None,
    effects: list[str] | None = None,   # advisory declared-intent metadata
    id_projection: str | None = None,   # structural param that receives the resolved target id
    background: bool = False,           # advisory per-tool hint
    long_running: bool = False,         # advisory per-tool hint
    data_model: type | None = None,     # SDL Typed Return Contract — sdl.Entity / sdl.EntityList[T]
) -> Callable:
    ...

The decorator is called with keyword or positional arguments and returns a decorator that wraps the handler function. The handler function is stored unchanged in _functions[name].


Kwargs reference

name

Required. The function identifier used in the LLM's tool-use calls. This name appears in the manifest, in audit logs, and in typed-dispatch calls.

Prop

Type

Rules:

  • Must be unique within the ChatExtension instance.
  • Use verb_noun style (create_note, list_tasks, delete_saved_query).
  • The tool name is not how the kernel finds your target ID — declare id_projection (the structural, SDL-aligned way) so the resolved ID threads into the right param regardless of the name (see below).

description

Required. The human-readable description shown to the LLM when it decides which tool to call.

Prop

Type

Best practices:

  • Always name the entity explicitly: "Create a new notes folder." not "Create a new folder." — the LLM has many extensions and needs to know you mean notes folders.
  • Include pagination behavior in read tools: "Returns up to limit rows. If has_more is true, call again with offset=offset+limit."
  • Do not include "I will" or "You should" phrasing — this is metadata, not a prompt.
  • V16 enforces ≥20 characters at ERROR severity. "Create note" (11 chars) fails validation.

Production example from notes/handlers_notes.py:

"List notes (paginated). Returns up to `limit` rows per call. If `has_more` is true, call again with `offset=offset+limit` to fetch the next page."

params

Optional explicit parameter schema. If omitted, the SDK auto-derives it from the Pydantic BaseModel annotation on the handler's second parameter.

Prop

Type

Preferred pattern (auto-derive):

from pydantic import BaseModel, Field

class CreateNoteParams(BaseModel):
    title: str = Field(description="Note title")
    content_text: str = Field("", description="Note body in plain text")
    tags: list[str] = Field(default_factory=list, description="Tag labels to apply")
    folder_id: str | None = Field(None, description="Folder UUID or name, or null for root")

@chat.function(
    "create_note",
    description="Create a new note with title, content, tags, and optional folder.",
    action_type="write",
    chain_callable=True,
    effects=["create:note"],
    event="created",
)
async def fn_create_note(ctx, params: CreateNoteParams) -> ActionResult:
    ...

Critical: module-scope model requirement (V17). The Pydantic model MUST be defined at module scope, not inside the function body. The SDK detects the model via func.__globals__; a function-local class is invisible to this lookup and triggers a V17 ERROR.

# CORRECT: module scope
class CreateNoteParams(BaseModel):
    title: str

@chat.function("create_note", ...)
async def fn_create_note(ctx, params: CreateNoteParams) -> ActionResult:
    ...

# WRONG: function-local — V17 ERROR, model not detected
@chat.function("create_note", ...)
async def fn_create_note(ctx, params) -> ActionResult:
    class CreateNoteParams(BaseModel):  # invisible to SDK
        title: str
    ...

No-arg functions (V17): Functions that take no user parameters (e.g. list_trash, empty_trash) still require a Pydantic param. Use a sentinel:

class NoParams(BaseModel):
    pass

@chat.function(
    "list_trash",
    description="List all notes currently in the trash.",
    action_type="read",
)
async def fn_list_trash(ctx, params: NoParams) -> ActionResult:
    ...

Field descriptions matter. The SDK serializes Field(description=...) directly into the tool-use schema. The LLM reads these descriptions when filling in parameter values. Short or missing descriptions produce poor argument quality.


action_type

Controls the authorization gate, confirmation behavior, and audit classification for this function.

Prop

Type

Semantic table:

ValueSide effectsKAV confirmationAudit rowchain_callable default
"read"NoneNeverYes (read)True (v4.2.10+)
"write"Creates or modifies dataWhen user has confirmations enabledYes (write)True
"destructive"Deletes, permanently removes, or cannot be undoneAlways — unconditionalYes (destructive)True

KAV (Pre-Authorized Action Execution) is the 2-step confirmation flow. When a write function is called and the user has confirmations enabled, the web-kernel presents a confirmation card before executing. destructive functions always present a confirmation card regardless of user settings.

Choose action_type carefully:

  • "read" — fetches data, no mutation: list_notes, get_task, search_notes, query_database
  • "write" — creates, updates, moves, or sends: create_note, update_task, send_email, move_note
  • "destructive" — deletes, permanently removes, empties, archives with no recovery: delete_note, permanent_delete_note, empty_trash, delete_task

When in doubt between "write" and "destructive": if the action cannot easily be undone, use "destructive".


event

The event suffix emitted after a successful write or destructive execution.

Prop

Type

V10 warns (and will become ERROR in v5.0.0) when a write or destructive function has no event. Events are consumed by:

  • Other extensions that subscribe via @ext.on_event
  • The audit ledger
  • SSE streams for real-time panel refresh

Production examples from notes/handlers_notes.py: event="created", event="updated", event="deleted", event="permanently_deleted", event="bulk_deleted".

The web-kernel automatically prepends the extension's app_id, so event="created" becomes "notes.created" in the stream.


data_model

Declares the SDL shape of this tool's ActionResult.data — the Typed Return Contract. The data_model you pass MUST be an SDL type: an sdl.Entity subclass for a single-record tool, or a concrete sdl.EntityList[T] subclass for a list tool. A plain dict, a plain pydantic.BaseModel, or a legacy {"notes": [...]}-style wrapper is not a valid data_model — the platform reads SDL entities, so your returned data must BE one.

Prop

Type

A single-record read returns an sdl.Entity subclass:

from imperal_sdk import sdl

class Note(sdl.Entity, sdl.Bodied, sdl.Categorized):
    folder_id: str | None = None

@chat.function(
    "get_note",
    description="Fetch a single note by id, with title, body, tags, and folder.",
    action_type="read",
    data_model=Note,   # ← SDL typed return shape
)
async def fn_get_note(ctx, params: NoteIdParams) -> ActionResult:
    n = await ctx.store.get("notes", params.note_id)
    return ActionResult.success(
        Note(id=n.id, title=n.data["title"], body=n.data.get("content_text"), folder_id=n.data.get("folder_id")),
        summary=f"Note: {n.data['title']}",
    )

A list read returns a concrete sdl.EntityList[T] subclass (never a {"notes": [...]} wrapper):

from imperal_sdk import sdl

class NoteList(sdl.EntityList[Note]):
    pass

@chat.function(
    "list_notes",
    description="List notes (paginated). Returns up to limit rows per call.",
    action_type="read",
    data_model=NoteList,
)
async def fn_list_notes(ctx, params: ListNotesParams) -> ActionResult:
    rows = await ctx.store.query("notes", limit=params.limit)
    items = [Note(id=r.id, title=r.data["title"]) for r in rows.data]
    return ActionResult.success(
        NoteList(items=items, total=rows.total, has_more=rows.has_more),
        summary=f"{len(items)} note(s) found.",
    )

data_model is required for action_type="read" (V23) so the platform can read the entity's roles, validate the typed return shape, and prevent input/output field-name drift. It is recommended (WARN-only) for write/destructive (V24) — return the resulting sdl.Entity so the narrator and audit ledger see the entity shape. See the SDL reference for the full facet/role library and the Validators reference.


chain_callable

Governs whether the platform issues a direct typed dispatch for your tool. Keep the default (True) — it's a federal field (V19 requires True on write/destructive tools).

Multi-step is the iterative loop

Webbee runs multi-step turns by iterating — it calls a tool, reads the real result from its context, then decides the next call (how multi-step works). chain_callable does not drive that loop; it governs the platform's typed-dispatch path and satisfies the V19 federal rule. Leave it True and don't reason about it as "opting into a chain."

Prop

Type

Default behavior: When chain_callable=None (omitted), the SDK sets it to True for every action_type since v4.2.10. Before v4.2.10 the default for reads was False — meaning read tools were typed-callable in chains only when authors set the flag explicitly.

Why this matters: In a multi-step turn (e.g. "find my pending tasks and email them to my manager"), the web-kernel needs to call each step with the exact structured data from the previous step. With chain_callable=True, it issues a deterministic typed app/func(args) dispatch — Pydantic-validated, no wrapper-LLM round inside your extension. With chain_callable=False, the web-kernel falls back to wrapper-LLM routing, which can paraphrase parameters and lose structured-data fidelity between steps.

Read intents in chains (v4.2.10+): the default flip means typed @chat.function reads such as list_notes, search_*, get_* now type-dispatch in chains as first-step data providers — no extra setup needed. Reads in conversational turns (open questions like "what's in my inbox?") continue to route via the wrapper-LLM chat path; the classifier distinguishes the two.

When to set chain_callable=False explicitly: for catch-all conversational handlers (a case_chat-style tool that takes the raw user message and decides what to do internally). Such handlers depend on the wrapper-LLM seeing the user's prose, so direct typed dispatch would bypass their design.

V19 enforces chain_callable=True on every write/destructive function at ERROR severity when actions_explicit=True.


effects

Advisory declared-intent metadata describing what this function mutates.

Prop

Type

Format: "verb:resource" strings where verb is one of create, update, delete, trash, send, archive, move, and resource is the entity name.

Production examples:

  • ["create:note"] — creates a single note
  • ["update:note"] — updates note fields
  • ["trash:note"] — moves to trash (soft delete)
  • ["delete:note"] — permanent deletion
  • ["trash:note", "delete:note"] — bulk delete with optional permanent flag
  • ["create:task"] — creates a task
  • ["update:task"] — updates task fields, marks complete, etc.
  • ["delete:task"] — deletes a task permanently

effects is advisory declared-intent metadata: it is emitted into your manifest and reserved for future platform use. The kernel does not currently consume it — the narrator and audit ledger do not read it to describe or record what changed (the narrator describes actions from the typed function calls and their SDL results). Declare it anyway to keep your tools' intent accurate and forward-compatible. V20 warns when absent on write/destructive.


id_projection

Declares which Pydantic params field carries the resolved target ID when Webbee later calls this tool.

Prop

Type

How the kernel resolves the target ID field — structurally. When Webbee resolves a target entity for your tool (e.g. "delete the Q3 note" → it resolves the note), it needs to know which params field receives that resolved target ID. It resolves that field structurally:

  1. Your explicit id_projection — always wins. This is the SDL-aligned, declared way; use it.
  2. Otherwise, the model's own *_id field as declared in your params model.

There is no name-based guessing: the kernel does not derive the field from the tool name (the old verb-prefix heuristic was purged on 2026-06-03). Declare id_projection explicitly and the resolution is deterministic regardless of how the tool is named.

Declare it whenever the target field is not a single obvious <noun>_id matching the entity:

  • delete_notes_from_folder — target is the folder → set id_projection="folder_id"
  • toggle_checklist_item — target is the parent task → set id_projection="task_id"
  • create_task — when a later call needs the project context → set id_projection="project_id"
  • permanent_delete_note — target is the note → set id_projection="note_id"

Production examples from notes/handlers_notes.py and tasks/handlers_crud.py:

@chat.function(
    "permanent_delete_note",
    action_type="destructive",
    chain_callable=True,
    id_projection="note_id",   # declare the target field structurally
    effects=["delete:note"],
    event="permanently_deleted",
    description="Permanently delete a note. Cannot be undone.",
)
async def fn_permanent_delete_note(ctx, params: NoteIdParams) -> ActionResult:
    ...

@chat.function(
    "create_task",
    action_type="write",
    chain_callable=True,
    id_projection="project_id",  # later call needs project context
    effects=["create:task"],
    event="task.created",
    description="Create a new task in a project. Returns task_id + full task details.",
)
async def create_task(ctx, params: CreateTaskParams) -> ActionResult:
    ...

When id_projection is set, the platform writes the resolved target ID into params[id_projection]. This closes the class of bugs where a tool receives a placeholder instead of the actual resource ID Webbee resolved for the target entity.


background and long_running — LONGRUN-V1 sugar (v4.2.13+)

Declarative wrapper over ctx.background_task(). When background=True, the SDK auto-wraps the function call in ctx.background_task() instead of running the handler synchronously.

Prop

Type

Usage:

no-check: decorator fragment - chat comes from the app module
@chat.function(
    "refine_output",
    description="Refine the given text via AI completion.",
    action_type="write",
    event="text_refined",
    background=True,        # ← auto-wrap in ctx.background_task
    long_running=False,     # default cap 180s; True → 1800s
)
async def refine_output(ctx, params: RefineParams) -> ActionResult:
    # The body runs DETACHED via ctx.background_task() under the hood.
    # No inner _work() coro, no manual task_id wiring.
    await ctx.progress(50, "Generating with AI")
    resp = await ctx.http.post(openai_url, json={...}, timeout=120)
    return ActionResult.success(
        summary="Refined output ready!",
        data={"text": resp.body["text"]},
    )

What the user sees in chat:

  1. Immediate — auto-generated ack: "Started 'refine_output' in background — the result will be sent to chat when it finishes." Carries the task_id in data.
  2. Later — your handler's returned ActionResult.success(...) rendered as a fresh bot turn when the work completes.

Sugar vs. explicit ctx.background_task(coro) — when to use which:

  • Sugar (background=True) — your handler body is entirely the long work; the auto-ack summary is acceptable.
  • Explicit (ctx.background_task(coro)) — you need a custom acknowledgement summary, want to choose background_task() conditionally at runtime, or run mixed sync + background work in the same handler.

Federal contract: the same background-task guarantees apply (see Federal contract). In particular, a background handler MUST return an ActionResult — that is the value delivered to chat as the follow-up turn when the work finishes.


action_type in depth

"read" — side-effect-free queries

Use for any function that only fetches data. The web-kernel skips the confirmation gate entirely. There is no audit write-action row (a read access row is still written).

chain_callable defaults to True for reads since v4.2.10 — typed read handlers (list_*, search_*, get_*) participate in chains as first-step data providers without any extra setup. Set it explicitly to False only for catch-all conversational handlers that rely on the wrapper-LLM seeing the raw user prose.

@chat.function(
    "list_notes",
    action_type="read",
    description=(
        "List notes (paginated). Returns up to `limit` rows per call. "
        "If `has_more` is true, call again with `offset=offset+limit`."
    ),
)
async def fn_list_notes(ctx, params: ListNotesParams) -> ActionResult:
    ...

"write" — mutations with optional confirmation

Use for create, update, move, send, or any reversible mutation. The confirmation gate fires only when the user has enabled confirmations in their settings.

V10 warns when event is absent. V19 requires chain_callable=True when actions_explicit=True.

@chat.function(
    "create_note",
    action_type="write",
    chain_callable=True,
    effects=["create:note"],
    event="created",
    description="Create a new note with title, content, tags, and optional folder.",
)
async def fn_create_note(ctx, params: CreateNoteParams) -> ActionResult:
    ...

"destructive" — always-confirmed permanent actions

Use for permanent deletion, irreversible state changes, or any action the user should be forced to confirm. The confirmation card is always shown regardless of user settings. This is the strongest signal — do not use it for reversible operations.

@chat.function(
    "delete_task",
    action_type="destructive",
    chain_callable=True,
    effects=["delete:task"],
    event="task.deleted",
    description="Permanently delete a task. Cannot be undone.",
)
async def delete_task(ctx, params: DeleteTaskParams) -> ActionResult:
    ...

Pydantic params

The Pydantic params model is the primary contract between the LLM and your handler.

Model definition rules

  1. Module scope (V17): Define the model at module scope, not inside the function.
  2. Typed fields: Every field must have a type annotation. str, int, bool, list[str], str | None, and nested models are all supported.
  3. Field descriptions: Use Field(description="...") on every field. The SDK serializes these into the LLM schema — they directly affect argument quality.
  4. Required vs optional: Fields without a default are required in the LLM call. Fields with Field(None, ...) or Field(default_factory=list, ...) are optional.
  5. Validators: Pydantic validators work normally. @field_validator, @model_validator, etc. are all supported.
from pydantic import BaseModel, Field, field_validator

class UpdateNoteParams(BaseModel):
    note_id: str = Field(description="UUID4 of the note to update")
    title: str | None = Field(None, description="New title, or null to keep current")
    content_text: str | None = Field(None, description="New body text, or null to keep current")
    tags: list[str] | None = Field(None, description="New tag list, or null to keep current")
    is_pinned: bool | None = Field(None, description="Pin or unpin the note, or null to keep current")

    @field_validator("note_id")
    @classmethod
    def validate_uuid(cls, v: str) -> str:
        import re
        if not re.match(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", v, re.I):
            raise ValueError("note_id must be a UUID4")
        return v

Pydantic feedback loop (v4.1.0+)

When the LLM passes arguments that fail Pydantic validation, the SDK automatically retries the LLM call up to 2 times with structured feedback:

  • The validation error is formatted into human-readable prose.
  • The feedback is appended to the conversation as a structured correction.
  • The LLM retries with the corrected parameters.
  • After 2 failed retries, the error surfaces to the user.

This means well-described Pydantic models with clear error messages dramatically improve the user experience. The validation error message is sent to the LLM as feedback — make it actionable.

Guaranteed behavior: The retry budget is hard-capped at 2 regardless of configuration. Retries are limited to Pydantic validation errors only — not network errors or handler exceptions. The feedback message is structured prose, not a raw Pydantic traceback.


Return types

All @chat.function handlers must return ActionResult. The return type annotation -> ActionResult is required (V18 ERROR).

ActionResult.success

ActionResult.success(
    data: sdl.Entity | sdl.EntityList | dict = {},
    summary: str = "",
    *,
    ui: UINode | None = None,
    refresh_panels: list[str] | None = None,
) -> ActionResult

summary is always displayed to the user in chat. Make it conversational and informative:

  • "Note created: My Shopping List"
  • "3 tasks completed."
  • "Email sent to alex@example.com."

data is the structured payload passed to a later tool call and stored in the audit ledger. Pass the SDL value that matches your declared data_model=: an sdl.Entity for a single record, or a concrete sdl.EntityList[T] for a list. The platform reads the entity's roles to thread fields into the next step's typed params. (A bare dict still works for un-typed legacy paths, but a typed @chat.function should return its SDL data_model instance.)

refresh_panels controls which panels refresh after the action:

  • None (default): all panels refresh.
  • []: no panels refresh.
  • ["sidebar"], ["sidebar", "editor"]: specific named panels.

ui attaches an inline UINode rendered directly in the chat response (not in a panel). Use for confirmation receipts, summaries, or quick-view cards.

# Note is an sdl.Entity subclass declared at module scope (and passed as data_model=Note)
return ActionResult.success(
    Note(id=note["id"], title=note["title"]),
    summary=f"Note created: {note['title']}",
    refresh_panels=["sidebar"],
)

ActionResult.error

ActionResult.error(
    error: str,
    retryable: bool = False,
) -> ActionResult

error is shown to the user. Keep it user-friendly — no raw exception strings, no internal paths, no class names. The SDK's Magic UX layer intercepts raw exceptions and presents a friendly message, but your explicit error messages are shown as-is and bypass that safety net.

retryable=True shows a retry button in the UI.

return ActionResult.error(
    "Folder not found. Use list_folders() to see available folders.",
    retryable=False,
)

Examples

Example 1: Read tool (SDL list)

A paginated list with no side effects. Uses action_type="read" and a data_model= that is a concrete sdl.EntityList[T] subclass — the read tool returns a real EntityList, never a {"notes": [...]} wrapper.

from imperal_sdk import Extension, sdl
from imperal_sdk.chat import ChatExtension, ActionResult
from pydantic import BaseModel, Field

ext = Extension(
    "notes",
    version="1.0.0",
    display_name="Notes",
    description="Notes extension — create, organize, and search your notes with AI.",
    icon="icon.svg",
    actions_explicit=True,
)

chat = ChatExtension(ext, tool_name="notes", description="Notes assistant")


class Note(sdl.Entity, sdl.Bodied, sdl.Categorized):
    folder_id: str | None = None


class NoteList(sdl.EntityList[Note]):
    pass


class ListNotesParams(BaseModel):
    folder_id: str | None = Field(None, description="Filter by folder UUID or name, or null for all notes")
    limit: int = Field(20, description="Number of notes per page (max 50)")
    offset: int = Field(0, description="Page offset for pagination")


@chat.function(  # type: ignore[attr-defined]
    "list_notes",
    action_type="read",
    data_model=NoteList,   # ← SDL list result; required for reads (V23)
    description=(
        "List notes (paginated). Returns up to `limit` rows per call. "
        "If `has_more` is true, call again with `offset=offset+limit`."
    ),
)
async def fn_list_notes(ctx, params: ListNotesParams) -> ActionResult:
    notes = await ctx.store.query(
        "notes",
        where={"folder_id": params.folder_id} if params.folder_id else None,
        limit=params.limit,
    )
    items = [Note(id=n.id, title=n.data["title"], tags=n.data.get("tags", [])) for n in notes.data]
    return ActionResult.success(
        NoteList(items=items, total=notes.total, has_more=notes.has_more),
        summary=f"{len(items)} note(s) found.",
    )

Example 2: Write tool (SDL entity)

Creates a resource. Uses action_type="write", chain_callable=True, effects, and event, and returns the resulting sdl.Entity (V24 recommends data_model= on writes so the narrator and audit ledger see the entity shape).

from imperal_sdk import Extension, sdl
from imperal_sdk.chat import ChatExtension, ActionResult
from pydantic import BaseModel, Field

ext = Extension(
    "notes",
    version="1.0.0",
    display_name="Notes",
    description="Notes extension — create, organize, and search your notes with AI.",
    icon="icon.svg",
    actions_explicit=True,
)

chat = ChatExtension(ext, tool_name="notes", description="Notes assistant")


class Note(sdl.Entity, sdl.Bodied):
    folder_id: str | None = None


class CreateNoteParams(BaseModel):
    title: str = Field(description="Note title")
    content_text: str = Field("", description="Note body in plain text")
    folder_id: str | None = Field(None, description="Folder UUID or name, or null for root")


@chat.function(  # type: ignore[attr-defined]
    "create_note",
    action_type="write",
    chain_callable=True,
    effects=["create:note"],
    event="created",
    data_model=Note,   # ← recommended on writes (V24): return the created entity
    description="Create a new note with title, content, and optional folder.",
)
async def fn_create_note(ctx, params: CreateNoteParams) -> ActionResult:
    if not params.title.strip() and not params.content_text.strip():
        return ActionResult.error(
            "Note must have a title or content. Pass title and/or content_text."
        )
    doc = await ctx.store.create("notes", {
        "title": params.title.strip(),
        "content_text": params.content_text,
        "folder_id": params.folder_id,
        "user_id": ctx.user.imperal_id,
    })
    return ActionResult.success(
        Note(id=doc.id, title=doc.data["title"], body=doc.data.get("content_text"), folder_id=doc.data.get("folder_id")),
        summary=f"Note created: {doc.data['title']}",
        refresh_panels=["sidebar"],
    )

Example 3: Destructive tool with id_projection

Permanently deletes a resource. Always shows a confirmation card. Declares id_projection="folder_id" so the kernel threads the resolved target ID into the right field structurally — the function name (compound) is never used to guess it. Returns a small sdl.Entity describing the bulk result.

from imperal_sdk import Extension, sdl
from imperal_sdk.chat import ChatExtension, ActionResult
from pydantic import BaseModel, Field

ext = Extension(
    "notes",
    version="1.0.0",
    display_name="Notes",
    description="Notes extension — create, organize, and search your notes with AI.",
    icon="icon.svg",
    actions_explicit=True,
)

chat = ChatExtension(ext, tool_name="notes", description="Notes assistant")


class BulkDeleteResult(sdl.Entity):
    folder_id: str
    deleted_count: int = 0
    permanent: bool = False


class DeleteNotesFromFolderParams(BaseModel):
    folder_id: str = Field(description="Folder UUID or name — all notes in this folder will be deleted")
    permanent: bool = Field(False, description="True to permanently delete; False to move to trash")


@chat.function(  # type: ignore[attr-defined]
    "delete_notes_from_folder",
    action_type="destructive",
    chain_callable=True,
    id_projection="folder_id",  # structural target-id field — declared, not name-guessed
    effects=["trash:note", "delete:note"],
    event="bulk_deleted",
    data_model=BulkDeleteResult,
    description=(
        "Delete ALL notes in a folder (bulk). By default moves them to trash; "
        "pass permanent=true to permanently delete. Cannot be undone when permanent=true."
    ),
)
async def fn_delete_notes_from_folder(
    ctx, params: DeleteNotesFromFolderParams
) -> ActionResult:
    notes = await ctx.store.query("notes", where={"folder_id": params.folder_id})
    if not notes.data:
        return ActionResult.success(
            BulkDeleteResult(id=params.folder_id, title="Bulk delete", folder_id=params.folder_id, deleted_count=0),
            summary="No notes in folder — nothing to delete.",
        )
    deleted = 0
    for note in notes.data:
        await ctx.store.delete("notes", note.id)
        deleted += 1
    action = "permanently deleted" if params.permanent else "moved to trash"
    return ActionResult.success(
        BulkDeleteResult(
            id=params.folder_id,
            title="Bulk delete",
            folder_id=params.folder_id,
            deleted_count=deleted,
            permanent=params.permanent,
        ),
        summary=f"{deleted} note(s) {action}.",
        refresh_panels=["sidebar"],
    )

Common pitfalls

V14–V24+V31 validator failures

ValidatorWhat failsFix
V16description shorter than 20 charsExpand to ≥20 chars; name the entity
V17No Pydantic BaseModel paramDefine a BaseModel class at module scope; use it as the second param
V17Model defined inside function bodyMove class definition to module scope
V18Missing -> ActionResult return annotationAdd return annotation
V19write/destructive without chain_callable=TrueAdd chain_callable=True
V20write/destructive without effectsAdd effects=["verb:resource"]
V23action_type="read" tool with no data_model=Add data_model= pointing at an sdl.Entity / concrete sdl.EntityList[T] (WARN, env-promotable to ERROR)
V24write/destructive tool with no data_model=Add data_model= returning the resulting sdl.Entity (WARN-only)
V10write/destructive without eventAdd event="past_tense_verb"
V24-ASTctx.skeleton.get() in a handler bodyRemove; use ctx.store, ctx.http, or ctx.cache instead

Pydantic retry budget exhausted

If the LLM consistently fails Pydantic validation after 2 retries, the error surfaces to the user. Common causes:

  • Field descriptions are absent or too terse for the LLM to understand the expected format.
  • Required fields are ambiguous — the LLM cannot infer what to pass.
  • Complex nested models without clear field documentation.

Fix: improve Field(description=...) on every required field, especially IDs and enums.

action_type mismatch

Using "write" for an action that is irreversible leads to a poor user experience — the confirmation card may not appear when the user expects it. Audit the semantics of every function:

  • "Move to trash" where recovery is available = "write"
  • "Delete permanently" = "destructive"
  • "Archive" where the user can unarchive = "write"
  • "Purge all history" with no recovery = "destructive"

Missing id_projection on a chain-callable tool

If a later tool call passes a placeholder instead of the resolved ID, the kernel could not find the target-ID field structurally — there was no id_projection and no obvious *_id field in your params model. The kernel does not guess the field from the tool name. Add id_projection="field_name" to declare the correct field explicitly.

ctx.skeleton access in handlers (V24)

ctx.skeleton.get() inside a @chat.function handler raises SkeletonAccessForbidden. Skeleton data is classifier input only — it is not a data source for runtime handlers. Use ctx.store, ctx.http, or ctx.cache to fetch live data inside handlers.

ChatExtension(model=...) deprecated

The model= constructor parameter is deprecated since SDK 3.3.0 and emits a once-per-process warning. LLM model resolution is handled by the web-kernel's context injection layer. Remove model= from all ChatExtension(...) calls.

tool_name= is not deprecated — it is the canonical registration key for the extension's chat tool (it appears in the manifest, the LLM prompt, and the guards). Keep it on every ChatExtension(...).


Cross-references

On this page