@chat.function reference
Every kwarg, every behavior, every action_type — full surface for the LLM-callable handler decorator
@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, ActionResultFunctions 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 = "",
event_schema: type | None = None,
chain_callable: bool | None = None,
effects: list[str] | None = None,
id_projection: str | None = None,
background: bool = False, # v4.2.13+ — LONGRUN-V1 sugar
long_running: bool = False, # v4.2.13+ — federal cap 180s/1800s
) -> 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 chain dispatch calls.
Prop
Type
Rules:
- Must be unique within the
ChatExtensioninstance. - Use
verb_nounstyle (create_note,list_tasks,delete_saved_query). - For compound multi-word targets where the verb-prefix heuristic would yield the wrong field, also set
id_projection(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(...)
async def fn_create_note(ctx, params: CreateNoteParams) -> ActionResult:
...
# WRONG: function-local — V17 ERROR, model not detected
@chat.function(...)
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:
| Value | Side effects | KAV confirmation | Audit row | chain_callable default |
|---|---|---|---|---|
"read" | None | Never | Yes (read) | True (v4.2.10+) |
"write" | Creates or modifies data | When user has confirmations enabled | Yes (write) | True |
"destructive" | Deletes, permanently removes, or cannot be undone | Always — unconditional | Yes (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.
event_schema
Optional Pydantic model for typed event data validation.
Prop
Type
Most extensions leave this None and pass raw dict in ActionResult.success(data=...). Set it when you publish structured events that other extensions consume via @ext.on_event.
chain_callable
Controls whether the chain orchestrator can call this function as a typed step in a multi-extension 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 chain (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
Side-effect declarations for the audit ledger and chain narrator.
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
The chain narrator reads effects to describe what changed in multi-step responses. The audit ledger stores them verbatim. V20 warns when absent on write/destructive — will be ERROR in SDK v5.0.0.
id_projection
Declares which Pydantic params field carries the resolved target ID when this function runs as a chain step.
Prop
Type
When to use it: The web-kernel uses a verb-prefix heuristic to locate the target ID field. For delete_note, it derives note_id. For complete_task, it derives task_id. This works for simple verb_noun names.
For compound names, the heuristic fails:
delete_notes_from_folder→ heuristic yieldsnotes_from_folder_id(wrong) → setid_projection="folder_id"toggle_checklist_item→ heuristic yieldschecklist_item_id(wrong) → setid_projection="task_id"create_task→ when the chain needs to know which project to create in → setid_projection="project_id"permanent_delete_note→ heuristic yieldspermanent_delete_note_id(wrong) → setid_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", # heuristic would fail for "permanent_delete"
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", # chain step 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 chain executor reads params[id_projection] to thread the resolved ID from a previous step. This closes the class of bugs where a chain step receives a placeholder instead of the actual resource ID from an earlier step.
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:
@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:
- Immediate — auto-generated ack: "Started 'refine_output' in background — the result will be sent to chat when it finishes." Carries the
task_idin data. - 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 choosebackground_task()conditionally at runtime, or run mixed sync + background work in the same handler.
Federal contract: same five I-LONGRUN-* invariants apply (see Federal contract). Note in particular I-LONGRUN-BG-CORO-RETURNS-ACTIONRESULT — the handler MUST return ActionResult.
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
- Module scope (V17): Define the model at module scope, not inside the function.
- Typed fields: Every field must have a type annotation.
str,int,bool,list[str],str | None, and nested models are all supported. - Field descriptions: Use
Field(description="...")on every field. The SDK serializes these into the LLM schema — they directly affect argument quality. - Required vs optional: Fields without a default are required in the LLM call. Fields with
Field(None, ...)orField(default_factory=list, ...)are optional. - 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 vPydantic 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 (I-PYDANTIC-RETRY-BUDGET) 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.
Federal invariants (I-PYDANTIC-RETRY-BUDGET/SCOPE/FEEDBACK-STRUCTURED/FC-SINGLE-APPEND/WIRE-FROZEN): The retry budget is hard-capped at 2 regardless of configuration. The scope of retries is 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: dict | BaseModel = {},
summary: str = "",
*,
ui: UINode | None = None,
refresh_panels: list[str] | None = None,
) -> ActionResultsummary 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 downstream chain steps and stored in the audit ledger. For chain steps, the data dict is what the next step reads via prior_step_results.
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.
return ActionResult.success(
data={"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,
) -> ActionResulterror 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 (I-MAGIC-UX-1/2) intercepts raw exceptions but your explicit error messages bypass it.
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
A paginated list with no side effects. Uses action_type="read", no event, no effects, no chain_callable.
from imperal_sdk import Extension
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 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",
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,
)
return ActionResult.success(
data={
"notes": [{"note_id": n.id, "title": n.data["title"]} for n in notes.data],
"has_more": notes.has_more,
"next_offset": params.offset + len(notes.data) if notes.has_more else None,
},
summary=f"{len(notes.data)} note(s) found.",
)Example 2: Write tool
Creates a resource. Uses action_type="write", chain_callable=True, effects, and event.
from imperal_sdk import Extension
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 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",
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(
data={"note_id": doc.id, "title": doc.data["title"]},
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. Uses id_projection because the function name has a compound prefix.
from imperal_sdk import Extension
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 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", # verb-prefix heuristic would yield wrong field name
effects=["trash:note", "delete:note"],
event="bulk_deleted",
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(
data={"deleted_count": 0, "folder_id": params.folder_id},
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(
data={"deleted_count": deleted, "folder_id": params.folder_id, "permanent": params.permanent},
summary=f"{deleted} note(s) {action}.",
refresh_panels=["sidebar"],
)Common pitfalls
V14–V22+V24+V31 validator failures
| Validator | What fails | Fix |
|---|---|---|
| V16 | description shorter than 20 chars | Expand to ≥20 chars; name the entity |
| V17 | No Pydantic BaseModel param | Define a BaseModel class at module scope; use it as the second param |
| V17 | Model defined inside function body | Move class definition to module scope |
| V18 | Missing -> ActionResult return annotation | Add return annotation |
| V19 | write/destructive without chain_callable=True | Add chain_callable=True |
| V20 | write/destructive without effects | Add effects=["verb:resource"] |
| V10 | write/destructive without event | Add event="past_tense_verb" |
| V24 | ctx.skeleton.get() in a handler body | Remove; 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 for compound tool names
If a chain step passes a placeholder instead of the resolved ID, check whether the tool name has a compound prefix that confuses the verb-prefix heuristic. 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.
Cross-references
Concepts: Chat functions
What chat functions are, how the LLM routes to them, and the full action authorization lifecycle.
SDK: manifest reference
How @chat.function declarations appear in imperal.json — V1-V24 validators.
SDK: decorators reference
All SDK decorators in one place — @ext.tool, @ext.skeleton, @ext.panel, and more.
SDK: Pydantic feedback loop
How the SDK retries LLM calls on validation failure and the five federal invariants.
SDK: validators reference
V1-V24 validators: rule codes, severity levels, checks, and fixes.