Imperal Docs
Guides

Pydantic typed arguments

Writing LLM-friendly Pydantic models that get the right call shape every time

When the LLM calls your @chat.function, it generates JSON args. The SDK validates those against your Pydantic model. The model is your contract with the LLM. A good model gets the right call on first try; a sloppy one triggers retries or fails.

The federal-clean model shape

from pydantic import BaseModel, Field
from typing import Literal

class SendMessageParams(BaseModel):
    to: list[str] = Field(
        description="Recipient email addresses. Must be valid email syntax.",
    )
    subject: str = Field(description="Email subject — concise, ≤120 chars.")
    body: str = Field(
        description="Email body. Plain text or HTML. Write the FULL body — never a placeholder like '<email body>'.",
    )
    importance: Literal["low", "normal", "high"] = Field(
        "normal",
        description="Email importance flag.",
    )
    cc: list[str] | None = Field(
        None,
        description="Optional CC. List of email addresses.",
    )
    reply_to_thread_id: str | None = Field(
        None,
        description="Optional thread UUID — only set if replying to an existing thread.",
    )

Five things make this work:

🏷️

Module-scope (V17)

The class lives at module top. Function-local models silently disable the retry loop.

📝

Every Field has description

The LLM reads descriptions. Empty descriptions = blind LLM = wrong calls.

🎯

Concrete examples in descriptions

ISO datetime example, email format, content vs placeholder warning. The LLM models on the example.

🔒

Literal[...] for closed enums

Don't use `str` when only 3 values are valid. Literal makes the JSON schema enum-tagged.

📭

Optional fields have defaults

`Field(None, ...)` or `Field([], ...)`. Anything optional must have a default — required fields are determined by default-presence.

Common pitfalls

The retry loop explained

When validation fails:

Round 1:
  LLM emits: {"title": "", "due_at": "tomorrow"}
  Pydantic:  ValidationError(missing=["title"], string_pattern=["due_at"])
  SDK:       format → "title: required field is missing. due_at: expected ISO datetime, got 'tomorrow'"
  SDK:       re-prompt LLM with the prose

Round 2:
  LLM emits: {"title": "Review budget", "due_at": "2026-06-13T17:00:00"}
  Pydantic:  ✅
  Handler runs.

Bounded at 2 retries. After that, the standard VALIDATION_MISSING_FIELD failure path fires.

See Pydantic feedback loop reference for the full invariant set.

Pydantic features worth using

from typing import Literal

class GenerateImageParams(BaseModel):
    style: Literal["realistic", "anime", "sketch", "watercolor"] = Field(
        description="Visual style. Pick one.",
    )
    size: Literal["small", "medium", "large"] = Field("medium", description="Output size.")

The LLM gets enum: ["realistic", "anime", "sketch", "watercolor"] in the JSON schema — it can't pick anything else.

from pydantic import BaseModel, Field, StringConstraints
from typing import Annotated

class CreateUserParams(BaseModel):
    username: Annotated[str, StringConstraints(min_length=3, max_length=32, pattern=r"^[a-z0-9_]+$")] = Field(
        description="Username — 3-32 chars, lowercase letters/digits/underscore only.",
    )
    email: Annotated[str, StringConstraints(pattern=r"^[^@]+@[^@]+\.[^@]+$")] = Field(
        description="User email address.",
    )
from pydantic import BaseModel, Field, field_validator

class CreateTaskParams(BaseModel):
    title: str = Field(description="Task title")
    due_at: str | None = Field(None, description="ISO datetime, optional.")

    @field_validator("title")
    @classmethod
    def title_not_just_whitespace(cls, v: str) -> str:
        if not v.strip():
            raise ValueError("Title must not be only whitespace.")
        return v.strip()

The validator's error message gets turned into prose feedback for the retry round.

from typing import Literal, Annotated, Union
from pydantic import Field

class TextNoteParams(BaseModel):
    kind: Literal["text"]
    content: str = Field(description="Plain-text content.")

class CodeNoteParams(BaseModel):
    kind: Literal["code"]
    language: str = Field(description="Programming language, e.g. 'python'.")
    code: str = Field(description="Source code.")

NoteParams = Annotated[Union[TextNoteParams, CodeNoteParams], Field(discriminator="kind")]

The LLM picks one branch by the kind discriminator — clean schema, clean validation.

Anti-pattern: weak descriptions

# ❌ Useless
class SendMessageParams(BaseModel):
    to: list[str] = Field(description="recipients")
    subject: str = Field(description="subject")
    body: str = Field(description="body")

The LLM can guess the structure but won't know edge cases. Costs you in retry budget and bad first-shot calls.

# ✅ Good
class SendMessageParams(BaseModel):
    to: list[str] = Field(description="Recipient email addresses. Must be RFC 5321-valid syntax. At least one required.")
    subject: str = Field(description="Subject line — concise, ≤120 chars, no leading 'Re:' (web-kernel adds for replies).")
    body: str = Field(description="Plain-text or HTML email body. Write the FULL body — never placeholders.")

Verifying your schema

Before publishing, run the manifest builder and inspect the generated imperal.json — every typed handler is emitted with its params_schema (the JSON schema the LLM will see):

imperal build
cat imperal.json | jq '.tools[] | select(.name=="send_message") | .params_schema'

If the schema looks confusing to you, it'll confuse the LLM too. Iterate descriptions on your Field(description=...) annotations and rebuild.

What's next

On this page