Recipe — create a note
Federal-clean note creation with full content generation handled by the classifier
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.
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.")from imperal_sdk import ChatExtension, ActionResult
from .schemas import CreateNoteParams
@chat.function(
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):
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.get("panel_url"),
}from imperal_sdk import Extension
from . import handlers_chat # noqa: F401
ext = Extension(
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:
- Picks
create_note. - Fills
title="Q3 strategy". - Generates the actual 150-word summary into
content_text.
Your handler stores it. Done.
Why this works
For typed single-action writes (confidence ≥0.8, ≤1 app), the web-kernel dispatches classifier args verbatim — no second LLM round. The classifier is capable of generating multi-paragraph content reliably when the description explicitly demands it. The schema description above is the authoritative pattern.
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(
description="Append content to an existing note.",
action_type="write",
id_projection="note_id",
)
async def append_to_note(ctx, params): ...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
Validators V18 + I-AH-1 catch some cases. Description-side: tell the LLM where to get the ID.