<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Wade Allen</title>
    <description>The latest articles on DEV Community by Wade Allen (@reactance0083).</description>
    <link>https://dev.to/reactance0083</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3950734%2Fc4ce08ef-7f98-4c06-a96f-46819ef1e75f.jpg</url>
      <title>DEV Community: Wade Allen</title>
      <link>https://dev.to/reactance0083</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/reactance0083"/>
    <language>en</language>
    <item>
      <title>Catch LLM hallucinations with multi-model consensus</title>
      <dc:creator>Wade Allen</dc:creator>
      <pubDate>Mon, 22 Jun 2026 15:30:02 +0000</pubDate>
      <link>https://dev.to/reactance0083/catch-llm-hallucinations-with-multi-model-consensus-3l6h</link>
      <guid>https://dev.to/reactance0083/catch-llm-hallucinations-with-multi-model-consensus-3l6h</guid>
      <description>&lt;p&gt;A single model gives you a single point of failure: when it's confidently wrong, you get no signal that it's wrong. A cheap, surprisingly effective guard is to ask the same question to a few independent models and use their &lt;strong&gt;agreement&lt;/strong&gt; as a confidence signal.&lt;/p&gt;

&lt;p&gt;The idea: fan the question out concurrently, then rank the answers by how much they agree with each other. When the models converge, you can trust the answer. When they diverge, you flag it for review instead of shipping a guess.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;difflib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;SequenceMatcher&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic_ai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;

&lt;span class="n"&gt;MODELS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;anthropic:claude-sonnet-4-6&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;openai:gpt-4.1&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="n"&gt;output&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;ask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;answers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;gather&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;MODELS&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="c1"&gt;# agreement = mean pairwise similarity of each answer to the others
&lt;/span&gt;    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;agree&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;others&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;answers&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SequenceMatcher&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;ratio&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;others&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;others&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;answers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;agree&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;answer&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;best&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;agreement&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;agree&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;best&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;agreement&lt;/code&gt; is high, the models independently reached the same place — a strong signal. If it's low, you've caught a disagreement &lt;em&gt;before&lt;/em&gt; it reached a user, and you can route to a human, ask a tie-breaker model, or return "uncertain".&lt;/p&gt;

&lt;p&gt;You can make the similarity check smarter (embeddings instead of &lt;code&gt;difflib&lt;/code&gt;), add a local model as a third vote, or weight by each model's self-reported confidence. But even this 20-line version turns "hope the model is right" into a measurable number you can gate on. Agreement isn't proof of truth — but disagreement is a reliable smoke alarm, and that's most of the value.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I package patterns like this as small open-source pydantic-ai + FastAPI templates — the repos are on &lt;a href="https://github.com/Reactance0083" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;, and complete, ready-to-run versions are on &lt;a href="https://reactance0083.gumroad.com" rel="noopener noreferrer"&gt;Gumroad&lt;/a&gt;. Feedback and issues welcome.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>python</category>
      <category>fastapi</category>
    </item>
    <item>
      <title>Route Every Prompt to the Cheapest Model: Building a Multi-LLM Cost Optimizer with Pydantic AI</title>
      <dc:creator>Wade Allen</dc:creator>
      <pubDate>Mon, 22 Jun 2026 13:01:24 +0000</pubDate>
      <link>https://dev.to/reactance0083/route-every-prompt-to-the-cheapest-model-building-a-multi-llm-cost-optimizer-with-pydantic-ai-1o25</link>
      <guid>https://dev.to/reactance0083/route-every-prompt-to-the-cheapest-model-building-a-multi-llm-cost-optimizer-with-pydantic-ai-1o25</guid>
      <description>

&lt;h2&gt;
  
  
  The Problem: Every Prompt Costs Money, But Not Every Prompt Needs GPT-4
&lt;/h2&gt;

&lt;p&gt;You're running an AI system in production. Some requests need Claude's reasoning depth. Others are simple classification tasks that Groq can handle in milliseconds for a fraction of the cost. The trap most teams fall into: they pick one model and stick with it.&lt;/p&gt;

&lt;p&gt;Here's the math that breaks you. Your engineering team sends a 50-token prompt asking "Is this email spam?" to GPT-4o. Cost: ~$0.015. Groq does the same thing for ~$0.0001. Scale that to 100k prompts per day across your product, and you're hemorrhaging $1,400 per month on decisions that don't need advanced reasoning.&lt;/p&gt;

&lt;p&gt;The other trap: building classification logic by hand. You write heuristics to detect "simple" vs. "complex" prompts. You maintain these rules. Six months later, edge cases pile up. You're debugging prompts that should have routed to Claude but hit GPT-4o instead. Now you're paying for wrong answers.&lt;/p&gt;

&lt;p&gt;The real problem isn't picking models. It's routing each individual prompt to the minimum sufficient model without building fragile classification logic. You need infrastructure that learns what "complex" means from actual patterns in your data, then makes routing decisions automatically.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Approach: Structured Classification + Intelligent Routing
&lt;/h2&gt;

&lt;p&gt;The solution uses three layers working together:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 1: Complexity Classification&lt;/strong&gt; - Instead of writing rules, you use a lightweight model (Claude Haiku) to analyze each incoming prompt and assign a complexity score. Haiku is cheap enough that the classification cost is negligible compared to the routing savings. It outputs structured JSON describing the prompt's requirements: reasoning depth needed, external knowledge, multi-step logic, etc.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 2: Model Selection&lt;/strong&gt; - Based on the classification output, you select the cheapest model that meets the requirements. This is a simple lookup table initially, but it becomes data-driven. You track which models succeed at which complexity levels, then optimize the routing rules over time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 3: Execution + Tracking&lt;/strong&gt; - You execute the prompt on the selected model, track the cost, track the latency, and feed that back into your cost database. This becomes your ground truth for future optimizations.&lt;/p&gt;

&lt;p&gt;Why pydantic-ai over alternatives? Langchain's flexible but opaque. Building with plain requests/openai libraries means you're implementing routing logic yourself. Pydantic-ai's structured outputs are type-safe, they integrate cleanly with Pydantic models for validation, and the agent-based pattern maps perfectly to this workflow: an agent that evaluates complexity, an agent that routes, an agent that executes.&lt;/p&gt;

&lt;p&gt;The key design decision: make the complexity classification itself cheap and fast. If it costs more to classify a prompt than to just run it on the cheapest model, the system fails. By using Haiku for classification and caching classification patterns, you ensure that the overhead is minimal.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Central Code Pattern: Classification-Driven Routing
&lt;/h2&gt;

&lt;p&gt;Here's the pattern that makes this work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseModel&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic_ai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;enum&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Enum&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;litellm&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ComplexityLevel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Enum&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;SIMPLE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;simple&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;MODERATE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;moderate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;COMPLEX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;complex&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PromptAnalysis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;complexity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ComplexityLevel&lt;/span&gt;
    &lt;span class="n"&gt;reasoning_required&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;
    &lt;span class="n"&gt;external_knowledge_needed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;
    &lt;span class="n"&gt;estimated_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;reasoning&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;

&lt;span class="c1"&gt;# Agent 1: Classify complexity
&lt;/span&gt;&lt;span class="n"&gt;classifier_agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;claude-3-5-haiku-20241022&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;result_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;PromptAnalysis&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@classifier_agent.system_prompt&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;classifier_system&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;You are a prompt complexity analyzer. Classify the incoming prompt.
    Return structured JSON with complexity level and reasoning.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

&lt;span class="c1"&gt;# Routing map: complexity -&amp;gt; list of models (ordered by cost)
&lt;/span&gt;&lt;span class="n"&gt;MODEL_ROUTING&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ComplexityLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SIMPLE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;groq/llama-3.1-8b&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;gpt-4o-mini&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;claude-3-5-haiku-20241022&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;ComplexityLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MODERATE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;gpt-4o-mini&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;claude-3-5-sonnet-20241022&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;ComplexityLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;COMPLEX&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;claude-3-5-sonnet-20241022&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;gpt-4-turbo&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;classify_and_route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;PromptAnalysis&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Classify prompt, select model, return both.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

    &lt;span class="c1"&gt;# Step 1: Classify
&lt;/span&gt;    &lt;span class="n"&gt;analysis&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;classifier_agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_prompt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Step 2: Select model from routing map
&lt;/span&gt;    &lt;span class="n"&gt;candidate_models&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;MODEL_ROUTING&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;analysis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;complexity&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;selected_model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;candidate_models&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# Cheapest first
&lt;/span&gt;
    &lt;span class="c1"&gt;# Step 3: Log for cost tracking
&lt;/span&gt;    &lt;span class="nf"&gt;log_routing_decision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;user_prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;complexity&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;analysis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;complexity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;selected_model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;selected_model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;estimated_cost&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;estimate_cost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;selected_model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;analysis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;estimated_tokens&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;analysis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;selected_model&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;execute_with_routing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Full pipeline: classify, route, execute.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

    &lt;span class="n"&gt;analysis&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;classify_and_route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_prompt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Execute on selected model using litellm for unified interface
&lt;/span&gt;    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;litellm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;completion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;user_prompt&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Track actual cost
&lt;/span&gt;    &lt;span class="nf"&gt;log_execution&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;prompt_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prompt_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;completion_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;completion_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;actual_cost&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;calculate_actual_cost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;analysis&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;analysis&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;selected_model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;response&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What each part does: The classifier agent uses Haiku (cheap) to produce structured PromptAnalysis output. The routing map is the decision tree: given complexity, pick the cheapest model that can handle it. The execution step uses litellm as a unified interface to multiple providers (Claude, GPT-4o, Groq all speak the same API through litellm). The logging is critical: without it, you can't optimize the routing over time.&lt;/p&gt;

&lt;p&gt;Why this pattern works: it separates concerns cleanly. Classification logic is isolated from routing logic. Model selection is deterministic and auditable. Cost tracking is built in from the start, not bolted on later.&lt;/p&gt;




&lt;h2&gt;
  
  
  Integration: Making It Real with FastAPI
&lt;/h2&gt;

&lt;p&gt;Here's where this lives in production:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;HTTPException&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi.responses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;JSONResponse&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/ask&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;ask_with_routing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;JSONResponse&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Main endpoint: user sends a prompt, gets routed response.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;user_prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prompt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;user_prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prompt required&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;execute_with_routing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_prompt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="c1"&gt;# Return response + metadata for frontend
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;JSONResponse&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;response&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;response&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;metadata&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;complexity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;analysis&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;complexity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model_used&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;selected_model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;estimated_cost&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;analysis&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;estimated_tokens&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.00001&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# approximate
&lt;/span&gt;            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;log_error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_prompt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="nd"&gt;@app.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/costs/today&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_daily_costs&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Real-time cost tracking endpoint.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

    &lt;span class="n"&gt;costs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fetch_costs_from_db&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;date&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;total_spend&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;costs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;total&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;by_model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;costs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;by_model&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;by_complexity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;costs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;by_complexity&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;savings_vs_always_gpt4o&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;costs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;total&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;costs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;total_prompts&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.015&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The data flow: user sends a prompt to /ask. FastAPI receives it, calls classify_and_route, executes on the selected model, logs everything to a database (PostgreSQL or similar), returns the response with metadata. A background worker periodically aggregates costs. The /costs/today endpoint gives real-time visibility into spending patterns.&lt;/p&gt;

&lt;p&gt;One gotcha worth knowing: litellm has rate limits per provider. If you route 1000 simple prompts to Groq simultaneously, you'll hit their API limits. You need a queue (Bull, Celery, or just asyncio.Queue) in front of execution to throttle by provider, not just by request count. Without it, your routing optimizer works great until you scale, then you're retrying failed requests that now route to expensive fallback models.&lt;/p&gt;




&lt;h2&gt;
  
  
  Tradeoffs and When NOT to Use This
&lt;/h2&gt;

&lt;p&gt;This approach adds complexity. You're running at least one extra LLM call per user prompt (the classifier), which adds 200-500ms latency. For real-time chat applications, this might be unacceptable.&lt;/p&gt;

&lt;p&gt;You also need to maintain the routing map. As new models launch or pricing changes, you update MODEL_ROUTING. This is manageable but not automatic.&lt;/p&gt;

&lt;p&gt;The classifier itself can be wrong. A prompt might be marked "simple" when it actually needs complex reasoning, and you'll get a wrong answer. You need monitoring and a fallback system (if the simple model fails, retry on a more capable model).&lt;/p&gt;

&lt;p&gt;When to choose something simpler: If your prompts are homogeneous (they're all the same type), just pick one good model and move on. If you have fewer than 10k prompts per month, the optimization overhead costs more than the savings. If latency is critical and sub-200ms matters, this adds too much latency.&lt;/p&gt;

&lt;p&gt;When this approach wins: you have diverse prompt types, cost is a serious constraint, and you can afford 200-500ms classification overhead. You're running thousands of prompts per month across different complexity levels.&lt;/p&gt;




&lt;h2&gt;
  
  
  Get the Full Implementation
&lt;/h2&gt;

&lt;p&gt;I packaged this as an open-source template on GitHub: &lt;a href="https://github.com/Reactance0083/pydantic-ai-multi-llm-cost-optimizer" rel="noopener noreferrer"&gt;https://github.com/Reactance0083/pydantic-ai-multi-llm-cost-optimizer&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The scaffold gives you the architecture and core patterns. It's a good foundation but lacks production details: error handling for rate limits, caching of classifications, database schema design, monitoring dashboards.&lt;/p&gt;

&lt;p&gt;The full production version with tests, retry logic, cached classifications, and example dashboards is on Gumroad: &lt;a href="https://reactance0083.gumroad.com/l/ztmlv" rel="noopener noreferrer"&gt;https://reactance0083.gumroad.com/l/ztmlv&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What specific part of multi-model routing is causing pain in your system? Are you currently over-provisioning to certain models, or is latency from classification a blocker for your use case? Drop a comment and let's talk through your constraints.&lt;/p&gt;

</description>
      <category>python</category>
      <category>llm</category>
      <category>costoptimization</category>
      <category>aiinfrastructure</category>
    </item>
    <item>
      <title>Stop hand-parsing LLM JSON: structured outputs with pydantic-ai</title>
      <dc:creator>Wade Allen</dc:creator>
      <pubDate>Sat, 20 Jun 2026 15:32:38 +0000</pubDate>
      <link>https://dev.to/reactance0083/stop-hand-parsing-llm-json-structured-outputs-with-pydantic-ai-47k9</link>
      <guid>https://dev.to/reactance0083/stop-hand-parsing-llm-json-structured-outputs-with-pydantic-ai-47k9</guid>
      <description>&lt;p&gt;If you have ever written &lt;code&gt;json.loads(response)&lt;/code&gt; around an LLM call and then a defensive &lt;code&gt;try/except&lt;/code&gt; because the model returned&lt;br&gt;
&lt;br&gt;
 &lt;code&gt;```json&lt;/code&gt;&lt;br&gt;
&lt;br&gt;
 fences, a trailing comma, or prose before the object — this is for you.&lt;/p&gt;

&lt;p&gt;The fix is to stop treating the model's output as text you parse, and start treating it as a typed object the library validates for you. With &lt;a href="https://ai.pydantic.dev" rel="noopener noreferrer"&gt;pydantic-ai&lt;/a&gt; you declare the shape once and get a validated Python object back, with a retry on the model when it doesn't conform.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Field&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic_ai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Invoice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;vendor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(...,&lt;/span&gt; &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Grand total in USD&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;due_date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

&lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;anthropic:claude-sonnet-4-6&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;output_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;Invoice&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
              &lt;span class="n"&gt;system_prompt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Extract the invoice fields from the text.&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run_sync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Acme Corp — $1,240.00 due 2026-07-01&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# 1240.0  (a float, already validated)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What you no longer write: the JSON fence stripping, the &lt;code&gt;KeyError&lt;/code&gt; guards, the float coercion, the "the model added an apology before the JSON" handling. If the model returns something that doesn't fit &lt;code&gt;Invoice&lt;/code&gt;, pydantic-ai sends the validation error back to the model and asks it to try again — so your application code only ever sees a clean object.&lt;/p&gt;

&lt;p&gt;Three things this buys you in production:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The type is the contract.&lt;/strong&gt; Your endpoint signature, your tests, and your prompt all agree, because they reference the same model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Failures are explicit.&lt;/strong&gt; A field that won't validate raises where you can catch it, instead of silently becoming &lt;code&gt;None&lt;/code&gt; three functions later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's auditable.&lt;/strong&gt; &lt;code&gt;result.output&lt;/code&gt; is a real object you can log, diff, and assert on.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The pattern scales from one field to a nested schema, and it's the same whether you're on Claude, GPT, or a local model. Once you've used it you stop writing parsers entirely.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I package patterns like this as small open-source pydantic-ai + FastAPI templates — the repos are on &lt;a href="https://github.com/Reactance0083" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;, and complete, ready-to-run versions are on &lt;a href="https://reactance0083.gumroad.com" rel="noopener noreferrer"&gt;Gumroad&lt;/a&gt;. Feedback and issues welcome.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>ai</category>
      <category>pydantic</category>
      <category>llm</category>
    </item>
    <item>
      <title>How I Built an AI Email Triage Agent That Creates Linear Issues and Fires Slack Alerts</title>
      <dc:creator>Wade Allen</dc:creator>
      <pubDate>Mon, 15 Jun 2026 13:05:07 +0000</pubDate>
      <link>https://dev.to/reactance0083/how-i-built-an-ai-email-triage-agent-that-creates-linear-issues-and-fires-slack-alerts-mjl</link>
      <guid>https://dev.to/reactance0083/how-i-built-an-ai-email-triage-agent-that-creates-linear-issues-and-fires-slack-alerts-mjl</guid>
      <description>&lt;h1&gt;
  
  
  How I Built an AI Email Triage Agent That Creates Linear Issues and Fires Slack Alerts
&lt;/h1&gt;

&lt;p&gt;Most engineering teams I talk to have the same problem: support emails pile up, someone manually reads them, decides what kind of issue it is, guesses at priority, creates a Linear ticket, and then pings the right person on Slack. That whole chain takes 5-15 minutes per email and happens inconsistently across team members.&lt;/p&gt;

&lt;p&gt;The obvious answer sounds like "just automate it." But after building this system, I'd reframe the actual problem: triage is a synchronization problem, not just an automation problem. Engineering teams using both GitHub and Linear need the &lt;em&gt;judgment calls&lt;/em&gt; - priority, story points, team assignment - to happen at the moment the email arrives, not hours later when a human finally gets to it. If a production outage report hits your inbox at 2am, the ticket should already exist with &lt;code&gt;P0&lt;/code&gt; priority and the on-call engineer assigned before anyone opens their laptop.&lt;/p&gt;

&lt;p&gt;This article walks through how I built exactly that with &lt;code&gt;pydantic-ai&lt;/code&gt;, FastAPI, Gmail IMAP, Linear API, and Slack webhooks.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem: Manual Triage Doesn't Scale
&lt;/h2&gt;

&lt;p&gt;Here is what the broken version looks like in practice.&lt;/p&gt;

&lt;p&gt;An inbound email arrives: "Hey, users in the EU region can't log in, getting 503 errors, this started about 20 minutes ago." Someone on your team reads it, decides it's a &lt;code&gt;bug&lt;/code&gt; of type &lt;code&gt;auth&lt;/code&gt;, marks it &lt;code&gt;P0&lt;/code&gt;, creates a Linear issue, and posts in &lt;code&gt;#incidents&lt;/code&gt; on Slack. That whole process works fine with two engineers and 10 emails a day.&lt;/p&gt;

&lt;p&gt;At 40 emails a day, it starts breaking. Things get missed. A critical auth failure sits in an inbox for 45 minutes because the person who usually triages it is in a meeting. Another email gets classified as &lt;code&gt;P2&lt;/code&gt; when it should be &lt;code&gt;P0&lt;/code&gt; because the triager didn't read closely enough. A third gets a Linear ticket created, but no Slack alert fires because the person forgot that step.&lt;/p&gt;

&lt;p&gt;The deeper problem: your team's triage logic exists entirely in people's heads. There is no consistent definition of what makes something &lt;code&gt;P0&lt;/code&gt; vs &lt;code&gt;P1&lt;/code&gt;. Different engineers make different calls. New hires make worse calls. And none of this is auditable.&lt;/p&gt;

&lt;p&gt;What you actually want: a system that reads the email, applies your team's specific classification logic, creates the Linear issue with correct metadata, and fires Slack alerts for anything critical - all within seconds of the email arriving, at any hour.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Approach: pydantic-ai + FastAPI for Structured Judgment
&lt;/h2&gt;

&lt;p&gt;The key architectural insight here is that LLM outputs need to be &lt;em&gt;trustworthy enough to trigger side effects&lt;/em&gt;. If your agent classifies an email as &lt;code&gt;P0&lt;/code&gt; and your code blindly creates a Linear issue and pages an on-call engineer, you need that classification to be a proper typed object, not a string you parse with regex.&lt;/p&gt;

&lt;p&gt;This is exactly where &lt;code&gt;pydantic-ai&lt;/code&gt; earns its place. Unlike plain OpenAI API calls where you prompt-engineer your way to JSON and hope it validates, &lt;code&gt;pydantic-ai&lt;/code&gt; lets you define the output schema as a Pydantic model and the library enforces it. The agent either returns a valid &lt;code&gt;TicketClassification&lt;/code&gt; object or raises an exception you can handle. No silent failures where &lt;code&gt;priority&lt;/code&gt; comes back as &lt;code&gt;"high"&lt;/code&gt; instead of &lt;code&gt;"P1"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Why not LangChain? I've used it. The abstraction layer is heavy, debugging is painful, and structured output handling requires enough boilerplate that you end up writing similar amounts of code with less visibility into what's happening. &lt;code&gt;pydantic-ai&lt;/code&gt; is thinner and more explicit - closer to writing a typed Python function that happens to call an LLM.&lt;/p&gt;

&lt;p&gt;Why FastAPI over a simple script? Because you want this running as a service. Gmail IMAP polling runs on a background task. The Linear and Slack integrations are async HTTP calls. FastAPI gives you a health endpoint, request logging, and a clean place to hang background tasks with &lt;code&gt;lifespan&lt;/code&gt; context managers. It also makes the system testable - you can POST a fake email payload to your &lt;code&gt;/triage&lt;/code&gt; endpoint in tests without touching Gmail at all.&lt;/p&gt;

&lt;p&gt;The design decision that makes this reliable: the classification and the side effects are separated. The agent produces a &lt;code&gt;TicketClassification&lt;/code&gt;. Then separate, deterministic functions consume that object to create the Linear issue and fire the Slack alert. The LLM never touches the API clients directly. If Linear is down, your classification still works. If the classification fails validation, nothing gets created.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Central Code Pattern
&lt;/h2&gt;

&lt;p&gt;Here is the core of the system - the classification agent and the downstream dispatch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseModel&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic_ai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;enum&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Enum&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Priority&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Enum&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;P0&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;P0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;P1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;P1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;P2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;P2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;P3&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;P3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;IssueType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Enum&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;BUG&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bug&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;FEATURE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;feature&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;QUESTION&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;question&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;INCIDENT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;incident&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TicketClassification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;issue_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IssueType&lt;/span&gt;
    &lt;span class="n"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Priority&lt;/span&gt;
    &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;suggested_team&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;story_points&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;requires_immediate_alert&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;

&lt;span class="c1"&gt;# The agent - structured output enforced by pydantic-ai
&lt;/span&gt;&lt;span class="n"&gt;triage_agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;openai:gpt-4o&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;result_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;TicketClassification&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;system_prompt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
    You are an engineering triage agent. Classify inbound support emails.
    P0: production down, data loss, security breach affecting users.
    P1: major feature broken, significant user impact, no workaround.
    P2: partial functionality broken, workaround exists.
    P3: minor issue, cosmetic, low user impact.
    story_points: 1-8 based on estimated fix complexity.
    requires_immediate_alert: true only for P0 and P1 incidents.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;process_email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Classification - LLM call with validated output
&lt;/span&gt;    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;triage_agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;classification&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;  &lt;span class="c1"&gt;# This is a real TicketClassification object
&lt;/span&gt;
    &lt;span class="c1"&gt;# Deterministic side effects - no LLM involved past this point
&lt;/span&gt;    &lt;span class="n"&gt;linear_issue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;create_linear_issue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;classification&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;classification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;requires_immediate_alert&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fire_slack_alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;classification&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;linear_issue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things worth noting here.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;result_type=TicketClassification&lt;/code&gt; is doing the heavy lifting. &lt;code&gt;pydantic-ai&lt;/code&gt; will retry the LLM call with validation feedback if the output doesn't conform to the schema. You get a real typed object back, not a dict.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;requires_immediate_alert&lt;/code&gt; boolean is intentional. You could derive this from priority in code (&lt;code&gt;if classification.priority in [Priority.P0, Priority.P1]&lt;/code&gt;), but having the LLM make this call explicitly means it can account for context. A &lt;code&gt;P2&lt;/code&gt; email that mentions "this is affecting 10,000 users right now" might warrant an alert that pure priority logic would miss.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;story_points&lt;/code&gt; gives Linear a starting estimate. It won't always be right, but having &lt;em&gt;something&lt;/em&gt; there is better than creating every issue with no estimate.&lt;/p&gt;




&lt;h2&gt;
  
  
  Integration: Gmail to Linear to Slack
&lt;/h2&gt;

&lt;p&gt;The data flow runs like this. A background task polls Gmail via IMAP every 60 seconds, fetching unread emails from a designated support address. Each email gets decoded from MIME format, stripped to plain text, and pushed to the classification pipeline. After classification, the Linear issue gets created via Linear's GraphQL API with the full metadata. If &lt;code&gt;requires_immediate_alert&lt;/code&gt; is true, a Slack webhook posts to your &lt;code&gt;#incidents&lt;/code&gt; or &lt;code&gt;#support&lt;/code&gt; channel with the issue link, priority, and summary.&lt;/p&gt;

&lt;p&gt;The Linear integration uses their GraphQL API directly rather than an SDK - the SDK adds little value here and the GraphQL call is straightforward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_linear_issue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;TicketClassification&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;LinearIssue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;mutation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
    mutation CreateIssue($input: IssueCreateInput!) {
      issueCreate(input: $input) {
        issue { id url title }
      }
    }
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;variables&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;description&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;priority&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;PRIORITY_MAP&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;estimate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;story_points&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;teamId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;TEAM_ID_MAP&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;suggested_team&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;# ... execute mutation
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Gotcha worth knowing&lt;/strong&gt;: Gmail IMAP with &lt;code&gt;UNSEEN&lt;/code&gt; search will re-fetch emails if your process restarts before you mark them as read. You need to mark emails as &lt;code&gt;SEEN&lt;/code&gt; immediately after fetching, before classification, not after. Otherwise a process crash between fetch and classification means you'll process the same email twice and create duplicate Linear issues.&lt;/p&gt;




&lt;h2&gt;
  
  
  Tradeoffs and Limitations
&lt;/h2&gt;

&lt;p&gt;This system has real limitations you should know going in.&lt;/p&gt;

&lt;p&gt;The classification quality depends heavily on your system prompt. GPT-4o is good, but it doesn't know your product's specific failure modes. A generic prompt will produce generic classifications. You will need to iterate on the prompt with real emails from your inbox before this is actually useful.&lt;/p&gt;

&lt;p&gt;IMAP polling has latency. Sixty-second poll intervals mean a &lt;code&gt;P0&lt;/code&gt; incident email might sit for up to a minute before it creates a ticket. For most teams this is fine. For true real-time needs, you'd want Gmail push notifications via Pub/Sub instead.&lt;/p&gt;

&lt;p&gt;This approach is probably overkill if your team gets fewer than 20 support emails per day. A simple Zapier workflow with a pre-defined category mapping would give you 80% of the value with zero maintenance. Use this when you have volume, varied email content, and need nuanced classification that rule-based systems get wrong.&lt;/p&gt;

&lt;p&gt;Cost is real but small. At ~1000 tokens per email with GPT-4o, 100 emails/day runs about $2-3/day. Not a blocker, but worth accounting for.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try It Yourself
&lt;/h2&gt;

&lt;p&gt;I packaged this as an open-source template on GitHub: &lt;a href="https://github.com/Reactance0083/pydantic-ai-email-linear-auto-triage" rel="noopener noreferrer"&gt;https://github.com/Reactance0083/pydantic-ai-email-linear-auto-triage&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The scaffold gives you the agent structure, FastAPI setup, and integration patterns to get started. The full production version with tests, error handling, retry logic, duplicate detection, and complete docs is available here: &lt;a href="https://reactance0083.gumroad.com/l/dcror" rel="noopener noreferrer"&gt;https://reactance0083.gumroad.com/l/dcror&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you're running a similar triage setup or have tried other approaches (Zapier, custom scripts, other agent frameworks), I'd genuinely like to hear how it's working. Drop a comment with what broke first - that's usually the most useful part of any production story.&lt;/p&gt;

</description>
      <category>python</category>
      <category>ai</category>
      <category>automation</category>
      <category>fastapi</category>
    </item>
    <item>
      <title>How I Built a Multi-Agent Prompt Engineering Runbook with pydantic-ai and FastAPI</title>
      <dc:creator>Wade Allen</dc:creator>
      <pubDate>Mon, 08 Jun 2026 13:05:31 +0000</pubDate>
      <link>https://dev.to/reactance0083/how-i-built-a-multi-agent-prompt-engineering-runbook-with-pydantic-ai-and-fastapi-1i5o</link>
      <guid>https://dev.to/reactance0083/how-i-built-a-multi-agent-prompt-engineering-runbook-with-pydantic-ai-and-fastapi-1i5o</guid>
      <description>&lt;h1&gt;
  
  
  How I Built a Multi-Agent Prompt Engineering Runbook with pydantic-ai and FastAPI
&lt;/h1&gt;

&lt;p&gt;Most teams building AI tooling eventually hit the same wall: they have five different prompt patterns scattered across Notion docs, Slack threads, and someone's local Python file. Nobody agrees on the output format. The SWOT analysis prompt returns markdown sometimes and JSON sometimes. The code reviewer just dumps text. When something breaks in production, you spend 40 minutes figuring out which version of the prompt was actually running.&lt;/p&gt;

&lt;p&gt;This article walks through an architecture that solves that problem using pydantic-ai, FastAPI, and structured Pydantic outputs. The result is a prompt engineering runbook: a single deployable service that handles SWOT analysis, social post generation, code review, multi-format summarisation, and a decision framework, all returning typed, validated responses.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem: Prompt Sprawl Kills Reliability
&lt;/h2&gt;

&lt;p&gt;Here is a concrete scenario that plays out in teams of five or more engineers.&lt;/p&gt;

&lt;p&gt;Someone writes a useful SWOT analyser prompt in a Jupyter notebook. It works great. A teammate copies it into a FastAPI route, changes a few words, and hardcodes the model name. Three months later, a third person builds a Slack bot that uses a slightly different version. Now you have three SWOT analysers in production with no shared contract on what the output looks like.&lt;/p&gt;

&lt;p&gt;Downstream systems start breaking because one version returns &lt;code&gt;strengths&lt;/code&gt; as a list and another returns it as a comma-separated string. The code reviewer prompt just returns raw text, so the frontend has to parse it with regex. When you upgrade the model, you have no idea which of the six prompt functions will silently regress.&lt;/p&gt;

&lt;p&gt;Teams that use Slack as their source of truth are the most exposed to this problem. Context lives in threads that expire from memory, decisions get buried, and when someone needs to extract structured insights from that context, they either do it manually or rely on informal scripts that nobody maintains. The chaos compounds because there is no single place that says "this is what our AI outputs look like."&lt;/p&gt;

&lt;p&gt;The fix is not better prompt writing. It is a typed contract layer between your prompts and the rest of your system.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Approach: pydantic-ai + FastAPI as a Typed Contract Layer
&lt;/h2&gt;

&lt;p&gt;The core idea is simple: every agent in the runbook has a Pydantic model as its output type. pydantic-ai enforces that contract at the LLM call boundary. FastAPI exposes each agent as an endpoint with typed request and response bodies.&lt;/p&gt;

&lt;p&gt;Why pydantic-ai over alternatives?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;LangChain&lt;/strong&gt; is the obvious comparison. LangChain has output parsers and structured output support, but the abstraction layer is thick. Debugging a failed parse means tracing through multiple internal chain objects. For a runbook that needs to be maintained by the whole team, that opacity is a liability.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Plain requests with &lt;code&gt;instructor&lt;/code&gt;&lt;/strong&gt; is closer to what this is doing, and honestly a valid choice. The tradeoff is that pydantic-ai gives you agent-level retries and tool support out of the box, which matters when you start adding context retrieval or multi-step reasoning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Raw OpenAI structured outputs&lt;/strong&gt; work but lock you to one provider. pydantic-ai is provider-agnostic, so swapping from OpenAI to Anthropic or a local model is a config change, not a rewrite.&lt;/p&gt;

&lt;p&gt;The key design decision that makes this reliable: every agent is defined with a &lt;code&gt;result_type&lt;/code&gt; that is a Pydantic model, not a string. pydantic-ai will retry the LLM call if the output fails validation. You get automatic retries with validation feedback fed back into the prompt. This is the thing that plain prompt engineering cannot give you on its own.&lt;/p&gt;

&lt;p&gt;The FastAPI layer adds HTTP-level validation on the way in and serialisation on the way out. Every request and response is typed. Your frontend, your Slack bot, and your CI pipeline all talk to the same contract.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Code Pattern: Typed Agents with Structured Outputs
&lt;/h2&gt;

&lt;p&gt;Here is the central pattern. Everything in the runbook follows this shape.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Field&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic_ai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;HTTPException&lt;/span&gt;

&lt;span class="c1"&gt;# 1. Define the output contract
&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SWOTAnalysis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;strengths&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Internal positive factors&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;weaknesses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Internal negative factors&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;opportunities&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;External positive factors&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;threats&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;External negative factors&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Two-sentence executive summary&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# 2. Define the input
&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SWOTRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Business or product context to analyse&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;focus_area&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Optional domain focus&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# 3. Create the agent with result_type enforcing the contract
&lt;/span&gt;&lt;span class="n"&gt;swot_agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;openai:gpt-4o&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;result_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;SWOTAnalysis&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;system_prompt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;You are a strategic analyst. Analyse the provided context and return &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a structured SWOT analysis. Be specific and actionable. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Each list should contain 3-5 items.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;# 4. Expose it as a typed FastAPI endpoint
&lt;/span&gt;&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/analyse/swot&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response_model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;SWOTAnalysis&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;analyse_swot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;SWOTRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;SWOTAnalysis&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;focus_area&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Focus area: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;focus_area&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;Context: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;swot_agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What each part does and why it matters:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;result_type=SWOTAnalysis&lt;/code&gt; is the critical line. This tells pydantic-ai to use the model's structured output mode and validate the response against your Pydantic schema. If the LLM returns malformed JSON or missing fields, pydantic-ai retries automatically.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;response_model=SWOTAnalysis&lt;/code&gt; on the FastAPI route means the OpenAPI docs are generated from your actual output type. Your frontend developers can see exactly what fields are returned without reading the prompt.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;result.data&lt;/code&gt; gives you the validated Pydantic instance directly. No JSON parsing, no &lt;code&gt;.get()&lt;/code&gt; calls with fallbacks.&lt;/p&gt;

&lt;p&gt;The same pattern is repeated for every agent in the runbook: code reviewer, social post generator, multi-format summariser, and decision framework. They each have a different Pydantic model and a different system prompt, but the structural shape is identical.&lt;/p&gt;




&lt;h2&gt;
  
  
  Integration: Connecting to External Sources
&lt;/h2&gt;

&lt;p&gt;The runbook becomes genuinely useful when it is connected to external data sources. The most impactful integration for most teams is Slack.&lt;/p&gt;

&lt;p&gt;The data flow looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Slack channel/thread
    -&amp;gt; Slack API (conversations.history or webhooks)
    -&amp;gt; extraction endpoint on the runbook
    -&amp;gt; summariser or SWOT agent
    -&amp;gt; structured output stored in Postgres or returned to Slack
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the Slack integration, you fetch message history using &lt;code&gt;slack_sdk&lt;/code&gt;, concatenate the thread into a single context string, and pass it to whichever agent fits the use case. Decision threads go to the decision framework agent. Product discussion threads go to the SWOT analyser. Code snippets shared in chat go to the code reviewer.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;slack_sdk&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;WebClient&lt;/span&gt;

&lt;span class="n"&gt;slack_client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;WebClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;slack_bot_token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;extract_thread_context&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;channel_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;thread_ts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;slack_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;conversations_replies&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;channel_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;ts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;thread_ts&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;messages&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;username&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One gotcha worth knowing: Slack message text contains user ID mentions in the format &lt;code&gt;&amp;lt;@U12345&amp;gt;&lt;/code&gt;. These will confuse the LLM if left in. Preprocess the context string to replace user IDs with display names or generic placeholders before passing to any agent. You can do this with the &lt;code&gt;users.info&lt;/code&gt; API call or by maintaining a local ID-to-name cache.&lt;/p&gt;




&lt;h2&gt;
  
  
  Tradeoffs and Limitations
&lt;/h2&gt;

&lt;p&gt;This architecture has real costs that you should weigh before building it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Latency.&lt;/strong&gt; Every request makes at least one LLM API call. For a code reviewer on a hot path, that is 1-3 seconds minimum. Do not use this for anything that needs sub-200ms response times.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retry costs.&lt;/strong&gt; pydantic-ai's automatic retries on validation failure mean a badly calibrated system prompt can silently double your API spend. Monitor retry rates and set &lt;code&gt;max_retries&lt;/code&gt; explicitly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Overkill for small teams.&lt;/strong&gt; If you have two engineers and three prompts, a shared Python module with well-named functions and type hints is probably the right answer. The FastAPI layer adds deployment overhead that only pays off when multiple systems are consuming the same agents.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Provider lock-in is deferred, not eliminated.&lt;/strong&gt; Switching providers is easier than with raw OpenAI calls, but system prompts that are tuned for GPT-4o may behave differently on Claude or Gemini. You still need to test across providers if portability matters.&lt;/p&gt;

&lt;p&gt;For teams with strict documentation habits already, the marginal value is lower. This runbook is most valuable when your AI prompts are currently scattered and your outputs are inconsistent.&lt;/p&gt;




&lt;h2&gt;
  
  
  Get the Code and Keep the Conversation Going
&lt;/h2&gt;

&lt;p&gt;I packaged this as an open-source template on GitHub: &lt;a href="https://github.com/Reactance0083/pydantic-ai-prompt-engineering-runbook" rel="noopener noreferrer"&gt;https://github.com/Reactance0083/pydantic-ai-prompt-engineering-runbook&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The scaffold gives you the core patterns for all five agents and the FastAPI setup. If you want the full production version with tests, error handling, provider configuration, logging middleware, and deployment docs, that is available here: &lt;a href="https://reactance0083.gumroad.com/l/mdsbpc" rel="noopener noreferrer"&gt;https://reactance0083.gumroad.com/l/mdsbpc&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you are building something similar and have hit a different set of tradeoffs, specifically around retry strategies or multi-tenant prompt isolation, I would like to hear about it in the comments. This architecture has a few rough edges I am still working through and real-world feedback tends to surface the problems that local testing misses.&lt;/p&gt;

</description>
      <category>python</category>
      <category>ai</category>
      <category>automation</category>
      <category>pydanticai</category>
    </item>
    <item>
      <title>How I Built an Email Auto-Triage System with pydantic-ai, FastAPI, and Linear</title>
      <dc:creator>Wade Allen</dc:creator>
      <pubDate>Thu, 04 Jun 2026 14:42:46 +0000</pubDate>
      <link>https://dev.to/reactance0083/how-i-built-an-email-auto-triage-system-with-pydantic-ai-fastapi-and-linear-1njb</link>
      <guid>https://dev.to/reactance0083/how-i-built-an-email-auto-triage-system-with-pydantic-ai-fastapi-and-linear-1njb</guid>
      <description>&lt;h1&gt;
  
  
  How I Built an Email Auto-Triage System with pydantic-ai, FastAPI, and Linear
&lt;/h1&gt;

&lt;p&gt;Support email is a graveyard of good intentions. Every team I've worked with has some version of the same problem: a shared inbox accumulates emails, someone manually reads them, decides it's a bug or a billing question, copies the text into a Linear ticket, assigns a priority based on gut feel, and maybe pings Slack if it seems urgent. This process takes 5-10 minutes per email on a good day, and it scales terribly.&lt;/p&gt;

&lt;p&gt;This article walks through the architecture and key code patterns for an automated triage pipeline that handles the full loop: classify incoming emails, create structured Linear issues, and fire Slack alerts for anything critical, all without a human in the loop.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem: Manual Triage Doesn't Scale
&lt;/h2&gt;

&lt;p&gt;Here's the concrete scenario that motivated this build.&lt;/p&gt;

&lt;p&gt;A small SaaS team receives 80-150 support emails per day. Three categories consistently matter: &lt;strong&gt;bugs&lt;/strong&gt; (customer-reported crashes or broken features), &lt;strong&gt;billing issues&lt;/strong&gt; (failed charges, incorrect invoices), and &lt;strong&gt;feature requests&lt;/strong&gt; (nice-to-haves that need product review). Everything else is general inquiry or noise.&lt;/p&gt;

&lt;p&gt;Without automation, what happens is this: emails pile up overnight. The first engineer on in the morning spends 45 minutes triaging before writing a single line of code. A P0 bug report from a paying customer that arrived at 2 AM sits unread until 9 AM. Billing issues that should route to a different Slack channel get lost in the engineering queue. Feature requests never make it into the backlog because nobody wants to do the copy-paste work.&lt;/p&gt;

&lt;p&gt;The real cost isn't the minutes per email. It's the decisions made inconsistently, the critical tickets that sit too long, and the cognitive load that comes with context-switching into support mode at the start of every day. Manual triage is a process that looks manageable until you actually measure it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Architecture: pydantic-ai + FastAPI as the Spine
&lt;/h2&gt;

&lt;p&gt;The core insight here is that email triage is a &lt;strong&gt;structured extraction problem&lt;/strong&gt;, not a generative one. You're not asking an LLM to write anything creative. You're asking it to read text and fill out a form with specific fields: category, priority, summary, suggested assignee. That's exactly what pydantic-ai is designed for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why pydantic-ai over LangChain or plain OpenAI requests?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;LangChain adds a lot of abstraction for problems that don't need it. Output parsers in LangChain feel bolted on. Plain OpenAI API calls require you to write JSON schema definitions manually and then validate the output yourself, which inevitably means writing brittle string parsing.&lt;/p&gt;

&lt;p&gt;pydantic-ai lets you define a Pydantic model as your expected output, and the library handles the prompting strategy and validation loop. If the LLM returns something malformed, pydantic-ai retries with the validation error included in context. In practice, this means you get typed, validated objects back from every agent call rather than dictionaries you hope have the right keys.&lt;/p&gt;

&lt;p&gt;FastAPI wraps the whole thing as a webhook endpoint. Gmail sends events via IMAP polling (or you can swap in a push webhook), the FastAPI handler processes the email through the agent, and then fires the Linear and Slack API calls. This keeps the pipeline stateless and easy to deploy.&lt;/p&gt;

&lt;p&gt;The key design decision: &lt;strong&gt;each email gets one agent call that returns a fully structured triage object&lt;/strong&gt;. There's no chain of calls, no memory, no conversation state. This makes the system predictable, cheap to run, and easy to debug. A single email costs roughly 300-500 input tokens, which at current GPT-4o-mini pricing is fractions of a cent.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Central Code Pattern: Structured Triage with pydantic-ai
&lt;/h2&gt;

&lt;p&gt;Here's the core of the system, simplified but real:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Field&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic_ai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;enum&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Enum&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;typing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Optional&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TicketCategory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Enum&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;BUG&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bug&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;BILLING&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;billing&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;FEATURE_REQUEST&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;feature_request&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;GENERAL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;general&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TicketPriority&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Enum&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;CRITICAL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;critical&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;HIGH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;high&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;MEDIUM&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;medium&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;LOW&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;low&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TriageResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;TicketCategory&lt;/span&gt;
    &lt;span class="n"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;TicketPriority&lt;/span&gt;
    &lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;One sentence summary of the issue, max 100 characters&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;customer_sentiment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Brief assessment: frustrated, neutral, or positive&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;suggested_team&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Which team should own this: engineering, billing, or product&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;needs_immediate_slack_alert&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;True only if CRITICAL priority or customer mentions churn/legal&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="n"&gt;TRIAGE_AGENT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;openai:gpt-4o-mini&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;result_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;TriageResult&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;system_prompt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
    You are a support triage specialist. Analyze incoming support emails and 
    classify them accurately. Be conservative with CRITICAL priority - only 
    use it for active outages, data loss, or customers threatening to cancel.
    Billing issues are almost always HIGH, not CRITICAL, unless the customer 
    reports fraudulent charges.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;triage_email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sender&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;TriageResult&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;email_content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
    From: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;sender&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;
    Subject: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;

    Body:
    &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  # truncate to keep tokens predictable
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;TRIAGE_AGENT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email_content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things worth explaining here:&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;Field(description=...)&lt;/code&gt; on each model field is not just documentation. pydantic-ai passes these descriptions into the schema that guides the LLM's output. This is how you constrain the model's behavior without writing verbose few-shot examples. The description on &lt;code&gt;needs_immediate_slack_alert&lt;/code&gt; embeds your business logic directly into the type definition.&lt;/p&gt;

&lt;p&gt;Body truncation at 2000 characters is deliberate. Support emails are either short (the important signal is in the first paragraph) or extremely long (forwarded threads, attached logs in pasted text). Truncating keeps costs predictable and prevents occasional emails from burning through your token budget.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;system_prompt&lt;/code&gt; includes explicit guidance about when NOT to use CRITICAL. Without this, LLMs tend to over-escalate because they have no sense of what your alert fatigue threshold is.&lt;/p&gt;




&lt;h2&gt;
  
  
  Integration: Gmail to Linear to Slack
&lt;/h2&gt;

&lt;p&gt;The data flow works like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A FastAPI background task polls Gmail via IMAP every 60 seconds, fetching unread emails from the support inbox.&lt;/li&gt;
&lt;li&gt;Each email runs through &lt;code&gt;triage_email()&lt;/code&gt; and returns a &lt;code&gt;TriageResult&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The result maps to a Linear issue via the Linear GraphQL API. Category becomes the label, priority maps to Linear's 1-4 scale, and the summary becomes the issue title.&lt;/li&gt;
&lt;li&gt;If &lt;code&gt;needs_immediate_slack_alert&lt;/code&gt; is true, the pipeline posts to a &lt;code&gt;#critical-support&lt;/code&gt; Slack channel with the sender, summary, and a direct link to the newly created Linear issue.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;process_email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ParsedEmail&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;triage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;triage_email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sender&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;linear_issue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;create_linear_issue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;triage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;priority&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;PRIORITY_MAP&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;triage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;triage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;team&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;triage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;suggested_team&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;triage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;needs_immediate_slack_alert&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;post_slack_alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;#critical-support&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*Critical ticket created*&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;From: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sender&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                    &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Issue: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;triage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;Linear: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;linear_issue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The gotcha worth knowing&lt;/strong&gt;: Linear's GraphQL API requires you to fetch team IDs and label IDs before you can create issues. These IDs are workspace-specific and not human-readable. The production version caches these at startup rather than fetching them on every email, which matters when you're processing a burst of 20 emails after an incident.&lt;/p&gt;




&lt;h2&gt;
  
  
  Tradeoffs and Limitations
&lt;/h2&gt;

&lt;p&gt;This approach works well for teams with relatively consistent email volume and well-defined categories. It does not handle a few things cleanly:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Thread context is lost.&lt;/strong&gt; Each email is processed independently. If a customer replies to an existing thread, the system will create a duplicate Linear issue rather than appending to the existing one. You need email threading logic (matching by subject or Message-ID header) to solve this, which adds meaningful complexity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;LLM classification has a tail of errors.&lt;/strong&gt; On roughly 3-5% of emails in testing, the category is wrong. Ambiguous emails ("Your tool deleted all my data but I also want to request a refund and ask about your enterprise plan") get assigned to whichever category the model prioritizes. You still want a human review queue for anything below HIGH priority.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;IMAP polling is not ideal for high volume.&lt;/strong&gt; If you're processing thousands of emails per day, you'll want to switch to Gmail's Pub/Sub push notifications or a proper email processing service. Polling every 60 seconds is fine for most support inboxes.&lt;/p&gt;

&lt;p&gt;For very low email volume, this is probably over-engineered. A simple filter rule plus a Zapier workflow might be the right call.&lt;/p&gt;




&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;This pipeline eliminated the morning triage ritual for the team that tested it. Engineers stopped starting their days by reading email. Critical tickets started landing in Slack within two minutes of arrival rather than hours later.&lt;/p&gt;

&lt;p&gt;I packaged this as an open-source template you can deploy in an afternoon:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub scaffold&lt;/strong&gt;: &lt;a href="https://github.com/Reactance0083/pydantic-ai-email-linear-auto-triage" rel="noopener noreferrer"&gt;https://github.com/Reactance0083/pydantic-ai-email-linear-auto-triage&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The scaffold gives you the core architecture. The full production version with proper error handling, retry logic, email thread deduplication, test suite, and deployment config is available here:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Full production code&lt;/strong&gt;: &lt;a href="https://reactance0083.gumroad.com/l/dcror" rel="noopener noreferrer"&gt;https://reactance0083.gumroad.com/l/dcror&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you've built something similar or run into different edge cases with LLM-based classification in production, I'd genuinely like to hear about it in the comments. Particularly curious whether anyone has solved the thread-matching problem cleanly.&lt;/p&gt;

</description>
      <category>python</category>
      <category>ai</category>
      <category>automation</category>
      <category>fastapi</category>
    </item>
    <item>
      <title>How I Built an Email-to-Linear Auto-Triage Agent with pydantic-ai and FastAPI</title>
      <dc:creator>Wade Allen</dc:creator>
      <pubDate>Mon, 01 Jun 2026 13:15:57 +0000</pubDate>
      <link>https://dev.to/reactance0083/how-i-built-an-email-to-linear-auto-triage-agent-with-pydantic-ai-and-fastapi-5ao6</link>
      <guid>https://dev.to/reactance0083/how-i-built-an-email-to-linear-auto-triage-agent-with-pydantic-ai-and-fastapi-5ao6</guid>
      <description>&lt;h1&gt;
  
  
  How I Built an Email-to-Linear Auto-Triage Agent with pydantic-ai and FastAPI
&lt;/h1&gt;

&lt;p&gt;Support engineers at most companies share a quiet frustration: they spend a chunk of every morning doing work that feels robotic. Read email, decide what type it is, guess the priority, open Linear, create a ticket, paste in the details, and maybe ping someone on Slack if it looks urgent. The work itself is mechanical. The judgment it requires is not always trivial, but the process absolutely is.&lt;/p&gt;

&lt;p&gt;I built a system that eliminates that loop using &lt;code&gt;pydantic-ai&lt;/code&gt;, FastAPI, Gmail IMAP, the Linear API, and the Slack API. This article explains the architecture, the key code pattern, and the honest tradeoffs you should know before using something like this in production.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem: Manual Triage Still Lives in Every Support Team
&lt;/h2&gt;

&lt;p&gt;Here is what actually happens without automation: a support email arrives at 2:47 AM. It says something like "our entire checkout flow is broken, no orders are going through." It sits in a shared inbox. Someone sees it at 8 AM. They manually create a Linear ticket, label it P1, assign it to the on-call engineer, and then fire off a Slack message. By that point, the company has lost five hours of potential revenue recovery.&lt;/p&gt;

&lt;p&gt;The frustrating part is that most teams &lt;em&gt;have&lt;/em&gt; tried to fix this. Zapier rules break when email subjects change slightly. Regex-based classifiers require constant maintenance as new email patterns appear. Full LangChain pipelines feel like overkill and introduce significant prompt engineering overhead when all you need is a structured classification step.&lt;/p&gt;

&lt;p&gt;The result: support teams manually drag emails into ticket systems because existing integrations are either too brittle or too heavy. What you actually need is a lightweight agent that can read an email, make a judgment call about its type and priority, and take structured action   without requiring a custom rule for every new ticket category that emerges over time.&lt;/p&gt;

&lt;p&gt;That gap is exactly what &lt;code&gt;pydantic-ai&lt;/code&gt; is designed to close.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Approach: Structured Outputs as the Glue Layer
&lt;/h2&gt;

&lt;p&gt;The core insight here is that &lt;code&gt;pydantic-ai&lt;/code&gt; lets you define exactly what you want an LLM to return, enforced at the library level. You are not hoping the model formats its response correctly. You are not parsing JSON out of a Markdown code block. The model's output is validated against a Pydantic model before your code ever sees it.&lt;/p&gt;

&lt;p&gt;Here is why that matters for email triage specifically: classification is only useful if downstream systems can consume it reliably. Linear's API expects specific field types. Slack's alert logic needs a boolean or an enum, not a string that might say "critical" or "Critical" or "very urgent" depending on the day. Structured output makes the LLM behave like a typed function.&lt;/p&gt;

&lt;p&gt;The architecture is straightforward:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;FastAPI&lt;/strong&gt; exposes a webhook endpoint that receives incoming email data (polled from Gmail via IMAP on a background scheduler).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;pydantic-ai agent&lt;/strong&gt; receives the raw email text, runs it through an LLM with a strict output schema, and returns a &lt;code&gt;TriageResult&lt;/code&gt; object.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;TriageResult&lt;/code&gt; is used to create a Linear issue via their GraphQL API.&lt;/li&gt;
&lt;li&gt;If &lt;code&gt;priority&lt;/code&gt; is &lt;code&gt;P1&lt;/code&gt; or &lt;code&gt;P2&lt;/code&gt;, a Slack alert fires to the on-call channel.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Why this over LangChain? LangChain's output parsers work, but they add layers of abstraction that obscure what is actually happening. When the parser fails in production, debugging is painful. &lt;code&gt;pydantic-ai&lt;/code&gt; is closer to the metal: you define a Pydantic model, you get that model back. The failure modes are explicit and easy to handle.&lt;/p&gt;

&lt;p&gt;Why FastAPI over a cron script? You get health check endpoints, async support, and easy deployment to any container environment. The IMAP polling runs as a background task, keeping the architecture clean and testable.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Code Pattern: Defining the Agent with a Typed Output Schema
&lt;/h2&gt;

&lt;p&gt;This is the piece developers need to understand before anything else. The entire system depends on this pattern working correctly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseModel&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic_ai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;enum&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Enum&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TicketType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Enum&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;BUG&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bug&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;BILLING&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;billing&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;FEATURE_REQUEST&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;feature_request&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;OUTAGE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;outage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;GENERAL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;general&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Priority&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Enum&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;P1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;P1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;P2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;P2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;P3&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;P3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;P4&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;P4&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TriageResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;ticket_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;TicketType&lt;/span&gt;
    &lt;span class="n"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Priority&lt;/span&gt;
    &lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;          &lt;span class="c1"&gt;# one sentence, max 120 chars
&lt;/span&gt;    &lt;span class="n"&gt;suggested_team&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;   &lt;span class="c1"&gt;# e.g. "backend", "billing", "platform"
&lt;/span&gt;    &lt;span class="n"&gt;requires_immediate_alert&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;

&lt;span class="n"&gt;triage_agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;openai:gpt-4o-mini&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;result_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;TriageResult&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;system_prompt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;You are a support triage agent. Given an email, classify it accurately. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Mark requires_immediate_alert=True only for outages or data loss scenarios. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Keep summary under 120 characters. Be conservative with P1   reserve it for &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;confirmed production outages affecting multiple users.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;triage_email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_email_text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;TriageResult&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;triage_agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_email_text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things worth explaining here:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;result_type=TriageResult&lt;/code&gt; is where the magic lives. &lt;code&gt;pydantic-ai&lt;/code&gt; constructs the prompt scaffolding to coerce the model into returning a response that validates against this schema. If validation fails, it retries automatically (configurable).&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;requires_immediate_alert&lt;/code&gt; boolean is intentional. Keeping alert logic inside the LLM's classification means you can tune it through the system prompt rather than adding conditional branches in your routing code. Want to tighten or loosen the alert threshold? Update the prompt. No code changes needed.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;suggested_team&lt;/code&gt; field is a free string rather than an enum because team names vary by organization. You validate it loosely downstream before routing.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Integration: Email In, Linear Out, Slack on Fire
&lt;/h2&gt;

&lt;p&gt;The data flow looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Gmail IMAP poll (every 60s)
    -&amp;gt; raw email extracted (subject + body)
    -&amp;gt; FastAPI background task queued
    -&amp;gt; pydantic-ai agent runs classification
    -&amp;gt; TriageResult returned
    -&amp;gt; Linear GraphQL mutation creates issue
    -&amp;gt; if requires_immediate_alert: Slack webhook fires
    -&amp;gt; email marked as read / label applied in Gmail
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Linear integration uses their GraphQL API. Creating an issue looks roughly like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;

&lt;span class="n"&gt;LINEAR_API_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.linear.app/graphql&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_linear_issue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;TriageResult&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;team_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;priority_map&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;P1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;P2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;P3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;P4&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;mutation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
    mutation CreateIssue($title: String!, $description: String!, 
                         $teamId: String!, $priority: Int!) {
      issueCreate(input: {
        title: $title,
        description: $description,
        teamId: $teamId,
        priority: $priority
      }) {
        issue { id url }
      }
    }
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;variables&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;description&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Type: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ticket_type&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;Suggested team: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;suggested_team&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;teamId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;team_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;priority&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;priority_map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AsyncClient&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;LINEAR_API_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;query&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;mutation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;variables&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;variables&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;One gotcha worth knowing&lt;/strong&gt;: Gmail IMAP with OAuth2 requires the &lt;code&gt;IMAPClient&lt;/code&gt; library and token refresh handling. If you use simple password authentication (which Google is deprecating for standard accounts), you will hit auth failures silently in some environments. Build in token refresh logic from day one, not as an afterthought.&lt;/p&gt;




&lt;h2&gt;
  
  
  Tradeoffs and Limitations
&lt;/h2&gt;

&lt;p&gt;This architecture works well for well-defined triage scenarios, but it has real limitations you should understand before deploying it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;LLM cost at volume&lt;/strong&gt;: If you are processing thousands of emails per day, even &lt;code&gt;gpt-4o-mini&lt;/code&gt; adds up. For very high volume, you would want to add a fast pre-filter (keyword matching or a fine-tuned small model) before hitting the LLM classification step.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hallucinated summaries&lt;/strong&gt;: The &lt;code&gt;summary&lt;/code&gt; field is free text generated by the model. Occasionally it will produce a summary that misrepresents the original email. This matters if your Linear issues are the system of record. Consider storing the raw email body as an attachment to the issue.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No threading awareness&lt;/strong&gt;: The system treats each email as independent. Reply chains and escalations require additional logic that this template does not handle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to choose something simpler&lt;/strong&gt;: If your email types are genuinely stable (three or four categories that never change), a rule-based system with regex matching will be cheaper, faster, and more predictable. LLM classification earns its complexity when the input space is messy and evolving.&lt;/p&gt;




&lt;h2&gt;
  
  
  Get the Code and Share What You Build
&lt;/h2&gt;

&lt;p&gt;I packaged this as an open-source scaffold on GitHub: &lt;a href="https://github.com/Reactance0083/pydantic-ai-email-linear-auto-triage" rel="noopener noreferrer"&gt;https://github.com/Reactance0083/pydantic-ai-email-linear-auto-triage&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The scaffold gives you the core structure: the &lt;code&gt;pydantic-ai&lt;/code&gt; agent definition, the FastAPI app skeleton, and stub integrations for Linear and Slack.&lt;/p&gt;

&lt;p&gt;The full production version with complete error handling, OAuth2 Gmail auth, retry logic, test coverage, and deployment docs is available here: &lt;a href="https://reactance0083.gumroad.com/l/dcror" rel="noopener noreferrer"&gt;https://reactance0083.gumroad.com/l/dcror&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you are already running something like this in production, or if you have hit edge cases I did not cover here (multi-language emails, CRM integration, SLA tracking), I would genuinely like to hear about it in the comments. The design decisions here are not the only valid ones, and the tradeoffs look different at different scales.&lt;/p&gt;

</description>
      <category>python</category>
      <category>ai</category>
      <category>automation</category>
      <category>fastapi</category>
    </item>
    <item>
      <title>How I Built a Customer Support Auto-Responder with Confidence Scoring Using pydantic-ai and FastAPI</title>
      <dc:creator>Wade Allen</dc:creator>
      <pubDate>Mon, 01 Jun 2026 13:07:55 +0000</pubDate>
      <link>https://dev.to/reactance0083/how-i-built-a-customer-support-auto-responder-with-confidence-scoring-using-pydantic-ai-and-fastapi-16fp</link>
      <guid>https://dev.to/reactance0083/how-i-built-a-customer-support-auto-responder-with-confidence-scoring-using-pydantic-ai-and-fastapi-16fp</guid>
      <description>&lt;h1&gt;
  
  
  How I Built a Customer Support Auto-Responder with Confidence Scoring Using pydantic-ai and FastAPI
&lt;/h1&gt;

&lt;p&gt;Support teams are drowning in tickets. Not because there are too many questions, but because the tooling makes it hard to automate the ones that should be automatic. Most tickets asking "how do I reset my password?" or "what are your refund terms?" get routed through the same queue as complex billing disputes. The answer to the first two exists in your docs. The answer to the third requires a human.&lt;/p&gt;

&lt;p&gt;The gap between "we have docs" and "the AI reliably answers from docs without hallucinating" is where most support automation projects die.&lt;/p&gt;

&lt;p&gt;This article walks through a production-grade pattern I built: a ticket ingestion system that uses RAG against your own documentation, scores its own confidence on every response, auto-replies when it's sure, and escalates to a human agent with a pre-drafted reply attached when it's not. Every decision is logged for audit.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem: Manual Triage at Scale Is Not a Strategy
&lt;/h2&gt;

&lt;p&gt;Here is the real scenario. Your support team gets 200 tickets per day. About 60% are answerable directly from your documentation. But your existing helpdesk either requires custom code per email format or rigid keyword-matching rules that break the moment a user phrases something slightly differently.&lt;/p&gt;

&lt;p&gt;The integration problem is worse than it looks. Most existing connectors expect emails in a predictable structure. Real users do not write like that. One person writes "how do I cancel," another writes "I need to stop my subscription immediately," and a third writes "billing is still happening after I closed my account." Same intent, wildly different phrasing.&lt;/p&gt;

&lt;p&gt;Without structured output from the LLM, you cannot reliably extract: what is the intent, what is the relevant doc section, and how confident is the model in its answer. So you end up with one of two bad outcomes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You auto-reply with a hallucinated answer and destroy user trust&lt;/li&gt;
&lt;li&gt;You route everything to humans and waste their time on questions your docs already answer&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What is missing is a structured decision layer that sits between raw LLM output and the action taken. That is exactly what pydantic-ai provides.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Approach: Structured Outputs as the Decision Layer
&lt;/h2&gt;

&lt;p&gt;The key insight is that pydantic-ai forces the LLM to return data in a validated schema rather than free text. This is not just cosmetic. When your model must produce a &lt;code&gt;TicketResponse&lt;/code&gt; object with a &lt;code&gt;confidence_score: float&lt;/code&gt;, a &lt;code&gt;suggested_reply: str&lt;/code&gt;, and an &lt;code&gt;escalate: bool&lt;/code&gt;, you can branch on those values programmatically. You are not parsing prose looking for signals. You have actual typed fields.&lt;/p&gt;

&lt;p&gt;Here is why this architecture beats the alternatives:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;vs. LangChain:&lt;/strong&gt; LangChain is flexible but the abstractions leak constantly. Debugging why a chain behaved unexpectedly is painful. For a system where every decision must be auditable, you want to see exactly what the model returned and why. pydantic-ai keeps the model call and the output schema co-located. You can inspect the raw response and the validated output side by side.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;vs. plain OpenAI/Anthropic requests:&lt;/strong&gt; You can use &lt;code&gt;response_format&lt;/code&gt; with JSON mode, but you still hand-roll the Pydantic models and the validation logic. pydantic-ai handles that contract automatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;vs. rigid rule engines:&lt;/strong&gt; Rules break on phrasing variations. A hybrid approach where the LLM handles intent extraction and the rules handle routing based on structured fields is much more robust.&lt;/p&gt;

&lt;p&gt;The architecture is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;FastAPI endpoint receives the ticket payload&lt;/li&gt;
&lt;li&gt;ChromaDB retrieves the top-k relevant doc chunks via embedding similarity&lt;/li&gt;
&lt;li&gt;pydantic-ai agent runs inference with the retrieved context&lt;/li&gt;
&lt;li&gt;The structured output determines: auto-reply, escalate with draft, or flag for review&lt;/li&gt;
&lt;li&gt;Every decision object is written to a PostgreSQL audit log&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The key design decision that makes this reliable is that the confidence threshold is not hardcoded in the prompt. It is a validated field the model must populate, and you set the threshold in your application logic. This means you can tune it without touching the prompt.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Code Pattern: Agent Definition and Confidence-Gated Routing
&lt;/h2&gt;

&lt;p&gt;Here is the central pattern. This is simplified but structurally accurate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Field&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic_ai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;chromadb&lt;/span&gt;

&lt;span class="c1"&gt;# The structured output schema
&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TicketResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Short label for ticket intent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;suggested_reply&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Full draft reply to send or attach&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;confidence_score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ge&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;le&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;escalate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;
    &lt;span class="n"&gt;escalation_reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;doc_sources&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default_factory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Agent with result type enforced
&lt;/span&gt;&lt;span class="n"&gt;support_agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;claude-3-5-sonnet-20241022&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;result_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;TicketResponse&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;system_prompt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
    You are a support assistant. Use only the provided documentation context.
    If the answer is not clearly supported by context, set confidence_score below 0.7
    and escalate to True. Always cite which doc sections informed your reply.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle_ticket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ticket_text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chroma_collection&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;TicketResponse&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Retrieve relevant docs
&lt;/span&gt;    &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;chroma_collection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;query_texts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ticket_text&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;n_results&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;context_chunks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;documents&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

    &lt;span class="n"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
    TICKET:
    &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ticket_text&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;

    DOCUMENTATION CONTEXT:
    &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;context_chunks&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;support_agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;  &lt;span class="c1"&gt;# Validated TicketResponse instance
&lt;/span&gt;
    &lt;span class="c1"&gt;# Confidence-gated routing -- no ambiguity
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;escalate&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;confidence_score&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.72&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;route_to_human&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;send_auto_reply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;log_decision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ticket_text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What each piece does and why it matters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;result_type=TicketResponse&lt;/code&gt; is the contract. The model cannot return something that does not fit this schema. pydantic-ai handles retries and validation errors internally.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;confidence_score&lt;/code&gt; with &lt;code&gt;ge=0.0, le=1.0&lt;/code&gt; enforced by Pydantic means you never get a string like "high" that you need to interpret. It is a float you can threshold on.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;doc_sources&lt;/code&gt; gives you audit traceability. You can show support managers which doc chunk informed which reply.&lt;/li&gt;
&lt;li&gt;The routing logic lives outside the prompt. This is intentional. Prompts drift. Application logic is version controlled.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;0.72&lt;/code&gt; threshold is arbitrary in this snippet. In production you tune it based on your false-positive tolerance, with audit logs providing the data to make that call.&lt;/p&gt;




&lt;h2&gt;
  
  
  Integration: Email Ingestion to Helpdesk to Slack Escalation
&lt;/h2&gt;

&lt;p&gt;The data flow end to end looks like this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inbound:&lt;/strong&gt; Emails arrive via a webhook from your email provider (Postmark, SendGrid, or similar). FastAPI receives the parsed payload with subject, body, sender, and any attachments.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Processing:&lt;/strong&gt; The ticket body hits the RAG pipeline. ChromaDB stores your docs as embeddings loaded at startup. The retrieval step happens in under 100ms for most collections under 50k chunks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Outbound:&lt;/strong&gt; If auto-reply triggers, the reply goes back through your email provider API. If escalation triggers, a Slack message goes to your &lt;code&gt;#support-escalations&lt;/code&gt; channel with the ticket details, the confidence score, and the pre-drafted reply attached. The agent did the work. The human just reviews and hits send (or edits first).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Audit log:&lt;/strong&gt; Every &lt;code&gt;TicketResponse&lt;/code&gt; object is serialized to JSON and written to a &lt;code&gt;ticket_decisions&lt;/code&gt; table. This includes the retrieved doc chunks used, the confidence score, whether it was auto-replied or escalated, and the timestamp.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gotcha worth knowing:&lt;/strong&gt; ChromaDB's default embedding model will embed your docs differently than the embedding used at query time if you change models mid-deployment. If you swap from &lt;code&gt;all-MiniLM-L6-v2&lt;/code&gt; to &lt;code&gt;text-embedding-3-small&lt;/code&gt;, you need to re-embed your entire document collection or retrieval quality degrades silently. Build a doc version hash into your collection name.&lt;/p&gt;




&lt;h2&gt;
  
  
  Tradeoffs and Limitations
&lt;/h2&gt;

&lt;p&gt;This architecture is not for every team. Honest assessment:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Latency:&lt;/strong&gt; Each ticket goes through an embedding query plus an LLM call. Expect 1-3 seconds per ticket depending on model and collection size. For real-time chat this is borderline. For email-based support, it is fine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RAG quality ceiling:&lt;/strong&gt; If your docs are poorly structured, out of date, or missing coverage for common questions, no amount of prompt engineering fixes it. Garbage in, garbage out. Budget for doc maintenance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost at volume:&lt;/strong&gt; At 200 tickets per day with Claude Sonnet, you are spending a few dollars per day. At 2000 tickets, that is meaningful. If budget is the constraint, a smaller model for the first triage pass plus a larger model only for borderline cases is a sensible optimization.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to skip this pattern:&lt;/strong&gt; If your ticket types are genuinely narrow and you can enumerate them, a smaller fine-tuned classifier plus templated replies is cheaper, faster, and more predictable. This pattern earns its complexity when ticket phrasing is diverse and your docs are the source of truth.&lt;/p&gt;




&lt;h2&gt;
  
  
  Get the Code
&lt;/h2&gt;

&lt;p&gt;I packaged this as an open-source template on GitHub: &lt;a href="https://github.com/Reactance0083/pydantic-ai-customer_support_ticket_ai_auto_responde" rel="noopener noreferrer"&gt;https://github.com/Reactance0083/pydantic-ai-customer_support_ticket_ai_auto_responde&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The scaffold shows the core agent setup, ChromaDB integration, and FastAPI routing. The full production version with test suite, error handling for malformed payloads, retry logic, Slack webhook integration, audit logging migrations, and deployment config is available here: &lt;a href="https://reactance0083.gumroad.com/l/qbvpl" rel="noopener noreferrer"&gt;https://reactance0083.gumroad.com/l/qbvpl&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you are running support at scale and have tried to automate it before, I am genuinely curious where it broke down for you. Was it retrieval quality, confidence calibration, the email parsing step, or something else entirely? Drop it in the comments. The edge cases in this space are worth discussing.&lt;/p&gt;

</description>
      <category>python</category>
      <category>ai</category>
      <category>automation</category>
      <category>llm</category>
    </item>
    <item>
      <title>Build an LLM Router with pydantic-ai: Route Prompts to the Cheapest Model</title>
      <dc:creator>Wade Allen</dc:creator>
      <pubDate>Mon, 25 May 2026 20:59:34 +0000</pubDate>
      <link>https://dev.to/reactance0083/how-i-built-an-llm-router-that-cut-my-api-costs-in-half-ik</link>
      <guid>https://dev.to/reactance0083/how-i-built-an-llm-router-that-cut-my-api-costs-in-half-ik</guid>
      <description>&lt;h2&gt;
  
  
  Why LLM Routing Matters
&lt;/h2&gt;

&lt;p&gt;Every LLM-powered application has the same hidden problem: you're using one model for every task, even though tasks vary wildly in complexity.&lt;/p&gt;

&lt;p&gt;A simple "classify this as spam or not spam" prompt doesn't need Claude Sonnet or GPT-4o. A /usr/bin/bash.04/MTok model handles it at 99% accuracy. But a complex multi-step reasoning task absolutely needs the flagship model, and getting cheap is just slow failure.&lt;/p&gt;

&lt;p&gt;The result: you're either wasting money on over-provisioning, or getting silent failures from under-provisioning. Usually both at the same time, on different parts of your pipeline.&lt;/p&gt;

&lt;p&gt;LLM routing solves this by classifying each prompt's complexity before routing it to the cheapest model that can actually handle it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture
&lt;/h2&gt;

&lt;p&gt;The multi-LLM cost optimizer I built uses three layers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Complexity Classifier&lt;/strong&gt; (Pydantic AI + Claude Haiku)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Model Router&lt;/strong&gt; (LiteLLM + dynamic pricing lookup)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost Tracker&lt;/strong&gt; (Real-time spend logging)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The key insight: the classifier (using a cheap fast model) pays for itself when it prevents expensive routing on simple tasks.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Core Pattern
&lt;/h2&gt;

&lt;p&gt;Pydantic AI structured outputs are what make the classification reliable:&lt;/p&gt;

&lt;p&gt;Without structured outputs, you are back to parsing free-text, and the classifier becomes another source of bugs. With Pydantic AI, you get a typed object back or an exception - no ambiguity.&lt;/p&gt;

&lt;p&gt;The router then picks the model based on the classified category:&lt;/p&gt;

&lt;h2&gt;
  
  
  The Real Trade-offs
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Classification latency adds overhead.&lt;/strong&gt; The complexity classifier runs before every routed call - around 200-400ms depending on the model. For interactive apps, cache classifications by semantic similarity so repeated similar prompts skip the classifier.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Edge cases are real.&lt;/strong&gt; Code-heavy prompts, domain-specific jargon, and ambiguous short prompts are where classifiers misfire. Build a feedback loop to log misclassifications so you can tune the routing thresholds over time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cheap models fail silently.&lt;/strong&gt; A simple model routing a task it cannot handle won't throw an error - it will just give you a worse answer. Add output validation downstream, not just routing logic upstream.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cold-start cost.&lt;/strong&gt; LiteLLM manages provider connections. First call to a new provider has connection overhead. Warm up your most-used routes at startup.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to Use This Pattern
&lt;/h2&gt;

&lt;p&gt;This pattern is high-value when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You have mixed workloads: classification, summarization, generation, reasoning&lt;/li&gt;
&lt;li&gt;Your API costs are already meaningful and growing&lt;/li&gt;
&lt;li&gt;You have multiple providers available (Anthropic, OpenAI, Groq all supported)&lt;/li&gt;
&lt;li&gt;You want a single FastAPI endpoint that handles routing transparently&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It adds complexity, so a single-model setup is fine when workloads are homogeneous or costs are still low.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Template
&lt;/h2&gt;

&lt;p&gt;I packaged this as a drop-in FastAPI + pydantic-ai template that you can have running in under 10 minutes. It includes the complexity classifier, LiteLLM router, cost tracker, and a /stats endpoint for real-time spend visibility.&lt;/p&gt;

&lt;p&gt;Get it at: &lt;a href="https://reactance0083.gumroad.com/l/ztmlv" rel="noopener noreferrer"&gt;https://reactance0083.gumroad.com/l/ztmlv&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you have questions about the routing logic or want to adapt it to a specific use case, open an issue on the GitHub repo: &lt;a href="https://github.com/Reactance0083/pydantic-ai-multi-llm-cost-optimizer" rel="noopener noreferrer"&gt;https://github.com/Reactance0083/pydantic-ai-multi-llm-cost-optimizer&lt;/a&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>ai</category>
      <category>llm</category>
      <category>fastapi</category>
    </item>
  </channel>
</rss>
