Imperal Docs
Recipes

Recipe — create a note

Create a note from chat in Webbee — a runnable extension recipe where the classifier generates the full note body itself and writes it into the tool args.

A note-creation tool. Demonstrates the content-field rule: when the user asks for content (an essay, a summary, a draft), the classifier itself generates the full content into args — there is no downstream "drafting LLM" that fills empty fields.

schemas.py
from pydantic import BaseModel, Field

class CreateNoteParams(BaseModel):
    title: str = Field(
        description="Short title for the note (1 sentence max).",
    )
    content_text: str = Field(
        description=(
            "The FULL note content. Write the actual content here. "
            "If the user asked for a 200-word essay, write all 200 words. "
            "NEVER write placeholders like '<essay 200 words>' — there is no downstream agent that will fill them."
        ),
    )
    folder_id: str | None = Field(
        None,
        description="Optional UUID of the folder to put the note in. Obtain from list_folders — never invent.",
    )
    tags: list[str] = Field(default_factory=list, description="Optional tags.")
handlers_chat.py
from imperal_sdk import ChatExtension, ActionResult
from main import ext              # the Extension declared in main.py
from .schemas import CreateNoteParams

# A ChatExtension instance is what provides the @chat.function decorator.
chat = ChatExtension(ext, "notes_mini", description="Create and manage notes in plain language.")

@chat.function(
    "create_note",
    description="Create a new note with the user's content. The content_text MUST be the actual content — never a placeholder.",
    action_type="write",
    effects=["note.create"],
)
async def create_note(ctx, params: CreateNoteParams) -> ActionResult:
    note = await ctx.http.post("/notes", json=params.model_dump(exclude_none=True))
    return ActionResult.success(
        summary=f"Created note: {note['title']}",
        data={"note_id": note["id"], "url": note.get("panel_url")},
    )
main.py
from imperal_sdk import Extension
from . import handlers_chat   # noqa: F401

ext = Extension(
    "notes-mini",
    display_name="Notes Mini",
    description="Create and manage notes in plain language.",
    icon="file-text",
    actions_explicit=True,
)

Try it in chat

"create a note titled 'Q3 strategy' — write a 150-word summary of why we're shifting focus to enterprise"

The classifier:

  1. Picks create_note.
  2. Fills title="Q3 strategy".
  3. Generates the actual 150-word summary into content_text.

Your handler stores it. Done.

Why this works

When you ask for content, Webbee writes the full content directly into the tool arguments and then runs your handler with those arguments — there is no separate downstream agent that fills empty fields. Your schema field description is what tells the agent to produce real content rather than a placeholder, so it is the authoritative pattern: spell out exactly what belongs in each field.

Variations

class AppendToNoteParams(BaseModel):
    note_id: str = Field(description="UUID of the note. Obtain from list_notes — never invent.")
    content_to_append: str = Field(description="The content to append. Markdown supported.")

@chat.function(
    "append_to_note",
    description="Append content to an existing note.",
    action_type="write",
    id_projection="note_id",
)
async def append_to_note(ctx, params) -> ActionResult: ...
class CreateNoteParams(BaseModel):
    title: str = Field(description="Note title.")
    content_markdown: str = Field(description="Note body in Markdown (use ## for headings, ** for bold, * for italics).")

The LLM writes Markdown when the description names it. Your storage / panel rendering handles MD → HTML.

class CreateNoteParams(BaseModel):
    title: str = Field(description="Note title.")
    content_text: str = Field(description="The full note content.")
    folder_id: str | None = Field(
        None,
        description="UUID of folder. Obtain from list_folders or resolve_folder. Never invent.",
    )

For chains where the user says "create a note in the work folder", the classifier first calls resolve_folder(name="work"), gets the folder UUID, then calls create_note(folder_id=<uuid>, ...).

What this avoids

❌

Empty content fields

If your description says 'optional content' the LLM may leave it empty expecting downstream filling. Always demand explicit content.

❌

Placeholder leaks

Without the 'NEVER write placeholders' clause, BYOLLMs sometimes emit '<200-word essay>' literal strings. Tightening the description fixed it federally.

❌

Hallucinated folder IDs

The platform rejects fabricated or placeholder IDs before your handler runs, but the durable fix is description-side: tell the model where to get the real ID (e.g. 'Obtain from list_folders — never invent').

Where to next

On this page