Imperal Docs
Core Concepts

@chat.function

The decorator that turns Python functions into LLM-callable tools — in depth

@chat.function is the most important decorator in the SDK. It's how you tell Webbee "here is a tool you can call when users speak". This page covers every option, every guarantee, every gotcha.

What it does

When you write:

@chat.function(
    description="Send an email message.",
)
async def send_message(ctx, params: SendMessageParams):
    ...

Three things happen at build time:

  1. The SDK extracts a JSON schema from SendMessageParams and embeds it in your manifest.
  2. The web-kernel registers send_message as a tool the intent classifier can choose.
  3. The Pydantic model becomes the runtime gatekeeper — every call is auto-validated.

At runtime, when the LLM picks your tool:

LLM: "I'll call send_message with {to: ['sarah@x.com'], subject: 'Q3'}"

SDK: Pydantic.SendMessageParams(**args) → ValidationError? retry up to 2× with prose feedback

SDK: ctx pre-flight (UNKNOWN_SUB_FUNCTION, FABRICATED_ID_SHAPE)

Your code: send_message(ctx, params)

SDK: audit chokepoint records (user_id, tenant_id, tool_name, action_type, status)

All decorator parameters

Prop

Type

The Pydantic params model — the right way

The federal-clean shape
from pydantic import BaseModel, Field

# ✅ MODULE-SCOPE — this is enforced by V17 validator
class SendMessageParams(BaseModel):
    to: list[str] = Field(description="Recipients — list of email addresses")
    subject: str = Field(description="Email subject line")
    body: str = Field(description="Plain-text or HTML body")
    cc: list[str] | None = Field(None, description="Optional CC recipients")
    reply_to_thread_id: str | None = Field(
        None,
        description="Optional thread UUID to reply within an existing conversation",
    )

@chat.function(
    description="Send an email message via the user's connected mail account.",
    action_type="write",
)
async def send_message(ctx, params: SendMessageParams):
    ...

Don't define Pydantic models inside functions

Function-local Pydantic models silently disable the auto-detection used by the SDK. The retry feedback loop won't run. V17 catches this at validate-time:

# ❌ DON'T
async def my_handler(ctx, **kwargs):
    class Params(BaseModel): ...   # function-local — V17 fails

What every Field needs

Field attributeWhyExample
description=LLM reads this to fill the param correctly"ISO datetime, e.g. 2026-06-15T09:00:00"
Default valueMarks the field as optional in the JSON schemaField(None, ...) or Field([], ...)
Type annotationDrives validation + JSON schemalist[str], str | None, Literal['a','b']

The fewer the required fields, the better — but each required field MUST have a description so the LLM doesn't hallucinate.

Action types

action_type is a federal classification that affects what the platform does before your handler runs:

👁️

read

Pure read. No side effects. No confirmation. The classifier may call this freely under chain mode.

✏️

write

Side-effecting. Audit row written. May appear in confirmation cards as part of a chain.

🔥

destructive

Irreversible (deletes, sends, charges). ALWAYS prompts the user with a confirmation card before firing.

@chat.function(description="Read the user's calendar.", action_type="read")          # No confirmation
@chat.function(description="Send an email.", action_type="write")                     # Audit only
@chat.function(description="Permanently delete a project.", action_type="destructive") # Confirmation card

The Pre-Authorized Action Execution flow

When a destructive (or chain of write/destructive) tool fires, the web-kernel:

  1. Intercepts the call before it executes.
  2. Renders a card to the user — "This will delete project 'Acme'. Confirm?"
  3. The user's "yes" hits a typed-iterate path that runs your handler with the exact same args, byte-for-byte. No LLM rerun.
  4. After execution, the audit row reflects user-confirmed acceptance.

This is federal invariant I-CONFIRMATION-EXECUTES-WHAT-USER-SAW — and it's all free for you.

Return shapes

Your handler returns a dict. The web-kernel renders it. You have a few useful conventions:

# Plain text reply — web-kernel just shows it in chat
return {"text": "Email sent to sarah@company.com."}

# Text + structured data — text in chat, data goes to chain orchestrator
return {
    "text": "Found 3 tasks",
    "tasks": [{"id": "...", "title": "..."}, ...],
    "count": 3,
}

# Card render — explicit UI block
return {
    "card": {
        "type": "message_preview",
        "subject": "Q3",
        "body_excerpt": "...",
    },
    "text": "Drafted. Send?",
}

# Error — web-kernel renders it as a chat error using i18n templates
return {"error": {"code": "NOT_FOUND", "detail": "No such project"}}

The federal-clean way: always return a text field (or an error field). It's what the user sees if everything else fails to render.

Chain mode — multi-step planning

When a user asks something like "check my unread mail and create a note for the most important one", the web-kernel runs chain mode:

  1. Classifier emits a multi-step plan: [mail.list_unread → notes.create_note]
  2. The web-kernel runs steps sequentially. The output of step 1 is available to step 2 via ctx.prior.
  3. Each step's audit row links back to the chain ID.

For your tool to participate in chain mode, just keep chain_callable=True (the default). Set chain_callable=False only if your tool cannot be safely composed (rare).

Typed dispatch in chains

Since SDK v4.0+, chain steps with action_plan from the classifier dispatch directly via Pydantic — no second LLM round in your extension. This closed a class of bugs where BYOLLM routers stochastically split tool calls across rounds.

The Pydantic feedback loop

When the LLM sends arguments that fail validation, the SDK does not immediately fail. Instead:

1. LLM emits create_task({description: "Q3 report"})  ← missing required 'title'
2. SDK: ValidationError("title: required field is missing")
3. SDK: format_pydantic_for_llm(e) → structured prose
4. SDK: re-prompt LLM with the prose feedback
5. LLM emits create_task({title: "Q3 report", description: "..."})
6. SDK: validates — OK — your handler runs

Bounded retry: at most 2 retries per tool_use, beyond that → standard VALIDATION_MISSING_FIELD failure.

This closed ~75% of arg-quality hallucinations in production. See the Pydantic feedback loop reference for invariants and observability.

Compound tool names — id_projection

Some tools have compound names like delete_notes_from_folder. The web-kernel needs to know which Pydantic field is the id for chain projection (where step 1 produced a folder, step 2 wants to consume that folder's id).

The default heuristic strips the verb and adds _id — so delete_notes_from_foldernotes_from_folder_id. That's wrong for this case (the field is folder_id).

Declare it explicitly:

@chat.function(
    description="Delete all notes inside a folder.",
    action_type="destructive",
    id_projection="folder_id",   # ← explicit, federal-clean
)
async def delete_notes_from_folder(ctx, params): ...

When you need this

Only when your tool name has more than one noun. Single-noun tools (delete_note, send_message) auto-derive correctly.

Common patterns

class SearchParams(BaseModel):
    query: str = Field(description="Search term")
    limit: int = Field(10, description="Max results to return")

@chat.function(
    description="Search the user's notes by full-text query.",
    action_type="read",
)
async def search_notes(ctx, params: SearchParams):
    rows = await ctx.http.get(f"/notes/search?q={params.query}&limit={params.limit}")
    return {
        "text": f"Found {len(rows)} match(es).",
        "results": rows,
    }
class CreateNoteParams(BaseModel):
    title: str = Field(description="Note title")
    content_text: str = Field(description="The full note content — write the actual content, not a placeholder")
    folder_id: str | None = Field(None, description="Optional UUID of folder to put it in")

@chat.function(
    description="Create a new note. The content_text MUST contain the actual user-requested content.",
    action_type="write",
)
async def create_note(ctx, params: CreateNoteParams):
    note = await ctx.http.post("/notes", json=params.model_dump(exclude_none=True))
    return {
        "text": f"Created note: {note['title']}",
        "note_id": note["id"],
        "url": note["panel_url"],
    }
class DeleteFolderParams(BaseModel):
    folder_id: str = Field(description="UUID of the folder to delete")

@chat.function(
    description="Permanently delete a folder and all its notes.",
    action_type="destructive",
    id_projection="folder_id",
)
async def delete_folder(ctx, params: DeleteFolderParams):
    # Confirmation card already shown before this runs
    await ctx.http.delete(f"/folders/{params.folder_id}")
    return {"text": "Folder deleted."}
class GetUnreadParams(BaseModel):
    limit: int = Field(20, description="Max unread to return")

@chat.function(
    description="Return the user's unread emails. Useful as the first step in chains like 'summarize my unread'.",
    action_type="read",
    chain_callable=True,
)
async def get_unread(ctx, params: GetUnreadParams):
    msgs = await ctx.http.get(f"/mail/unread?limit={params.limit}")
    return {
        "text": f"{len(msgs)} unread.",
        "messages": msgs,           # ← available to next chain step via ctx.prior.get_unread.messages
    }

Things that bite

What's next

On this page