@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:
- The SDK extracts a JSON schema from
SendMessageParamsand embeds it in your manifest. - The web-kernel registers
send_messageas a tool the intent classifier can choose. - 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
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 failsWhat every Field needs
| Field attribute | Why | Example |
|---|---|---|
description= | LLM reads this to fill the param correctly | "ISO datetime, e.g. 2026-06-15T09:00:00" |
| Default value | Marks the field as optional in the JSON schema | Field(None, ...) or Field([], ...) |
| Type annotation | Drives validation + JSON schema | list[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 cardThe Pre-Authorized Action Execution flow
When a destructive (or chain of write/destructive) tool fires, the web-kernel:
- Intercepts the call before it executes.
- Renders a card to the user — "This will delete project 'Acme'. Confirm?"
- The user's "yes" hits a typed-iterate path that runs your handler with the exact same args, byte-for-byte. No LLM rerun.
- 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:
- Classifier emits a multi-step plan:
[mail.list_unread → notes.create_note] - The web-kernel runs steps sequentially. The output of step 1 is available to step 2 via
ctx.prior. - 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 runsBounded 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_folder → notes_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
}