Typed Intents¶
Intent is generic. Annotate a handler parameter as Intent[MyPydanticModel] and the framework will:
- Extract the Pydantic schema at registration time.
- Ask the LLM to produce a payload that matches the schema.
- Validate the LLM's output and hand the handler a fully typed
intent.payload.
This turns natural-language agents into structured APIs without hand-written parsers.
Note
The typed-intent model is fully implemented in the framework, and MockBackend fully exercises structured responses. The built-in Anthropic, OpenAI, and Gemini backends do not yet translate LLMPrompt.response_schema into provider-native structured-output APIs, so provider-side schema enforcement is still partial today.
Motivation¶
Untyped intents give you intent.raw (a string) and intent.parameters (an untyped dict). That's fine for demos, but real handlers usually want something like:
- "A user ID, a list of product SKUs, and an optional comment."
- "A date range, a currency, and a grouping dimension."
- "One of three enum values plus an integer between 1 and 100."
Typed intents let you declare that contract once — as a Pydantic model — and have both the LLM and the runtime enforce it.
Example¶
from typing import Literal
from pydantic import BaseModel, Field
from agenticapi import AgenticApp, AgentResponse, Intent
from agenticapi.runtime.context import AgentContext
class OrderSearch(BaseModel):
"""Query parameters for searching orders."""
status: Literal["pending", "shipped", "cancelled"] | None = None
min_total_usd: float = Field(default=0.0, ge=0.0)
customer_email: str | None = None
limit: int = Field(default=20, ge=1, le=100)
app = AgenticApp(title="orders")
@app.agent_endpoint(name="orders.search")
async def search_orders(
intent: Intent[OrderSearch],
context: AgentContext,
) -> AgentResponse:
query: OrderSearch = intent.payload # fully typed, fully validated
rows = await db.search_orders(
status=query.status,
min_total=query.min_total_usd,
email=query.customer_email,
limit=query.limit,
)
return AgentResponse(result={"orders": rows})
A request like:
will flow as:
IntentParsersees that the handler expectsIntent[OrderSearch].- The parser builds a prompt and forwards
OrderSearch's schema viaLLMPrompt.response_schema. - The LLM returns, say,
{"status": "shipped", "min_total_usd": 500.0, "customer_email": "alice@example.com", "limit": 20}. - The framework validates the response against
OrderSearch; if it fails, validation/fallback logic decides whether to recover or raiseIntentParseError. - The handler runs with
intent.payloadalready anOrderSearchinstance.
How the schema reaches the parser¶
The dependency scanner inspects the handler signature at registration time. When it finds an Intent[T] annotation, it records the type parameter and forwards its JSON Schema to the IntentParser whenever that endpoint is invoked. No runtime signature inspection happens per request.
This means:
- Registration-time errors if
Tisn't a valid Pydantic model (fail fast). - Zero per-request overhead beyond the LLM call itself.
- The schema is the same one a Pydantic model would produce — you can
.model_json_schema()on it to see exactly what the LLM is being asked for.
What counts as a valid T?¶
Any of the following work as the type parameter:
- A Pydantic
BaseModelsubclass - A dataclass (Pydantic will generate a schema via
TypeAdapter) - A
TypedDict - Primitive types like
int,str,list[str]— rarely useful but supported for completeness
For serious use, always use a Pydantic model. Docstrings on fields become schema descriptions, which the LLM uses to disambiguate.
Handling validation failures¶
If the LLM returns JSON that doesn't validate against T, the parser raises IntentParseError. You can:
- Let it propagate (default — returns HTTP 400 to the client).
- Catch it in a pipeline stage or middleware and retry with a corrective prompt.
- Configure the LLM backend's retry policy so validation errors feed back into a new parse attempt.
Bare Intent still works¶
Intent without a type parameter is still supported — intent.payload is then None and you read intent.raw / intent.parameters like before. Typed and untyped endpoints can coexist in the same app.
Combining with Depends()¶
Typed intents compose naturally with dependency injection:
@app.agent_endpoint(name="orders.create")
async def create_order(
intent: Intent[NewOrder],
context: AgentContext,
db=Depends(get_db),
user: User = Depends(current_user),
) -> AgentResponse:
order = await db.insert_order(user_id=user.id, **intent.payload.model_dump())
return AgentResponse(result={"order_id": order.id})
The scanner handles the generic annotation and the dependency markers in the same pass — no extra configuration.
Best practices¶
- Write docstrings on every field. They become schema descriptions and dramatically improve LLM output quality.
- Use
LiteralandEnumaggressively. They're the cheapest way to constrain a model. - Add validators with
Field(..., ge=..., le=...). They become JSON Schemaminimum/maximum, which the LLM respects. - Keep models small. Deeply nested or huge models make the prompt longer and the output more error-prone. Split them into separate endpoints when possible.
- Don't reuse one giant model across endpoints. Each endpoint's model should capture exactly what that endpoint needs.
See API Reference → Interface for the full Intent[T] type.